Repository: Drumber/Kitsune Branch: master Commit: 58055468fd42 Files: 740 Total size: 2.0 MB Directory structure: gitextract_3d2wqgev/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── build.yml │ ├── reproducible-build.yml │ └── test.yml ├── .gitignore ├── .idea/ │ ├── .gitignore │ ├── AndroidProjectSystem.xml │ ├── codeStyles/ │ │ ├── Project.xml │ │ └── codeStyleConfig.xml │ ├── compiler.xml │ └── kotlinc.xml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── gradle.properties │ ├── proguard-rules.pro │ ├── schemas/ │ │ └── io.github.drumber.kitsune.data.source.local.LocalDatabase/ │ │ ├── 1.json │ │ ├── 2.json │ │ └── 3.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── io/ │ │ └── github/ │ │ └── drumber/ │ │ └── kitsune/ │ │ ├── fastlane/ │ │ │ └── CaptureScreenshots.kt │ │ ├── navigation/ │ │ │ └── NavigationTest.kt │ │ └── utils/ │ │ ├── OkHttpIdlingResource.kt │ │ ├── SearchViewActions.kt │ │ ├── ViewActions.kt │ │ ├── WaitForView.kt │ │ └── filter/ │ │ ├── RequiresScreenshotMode.kt │ │ └── ScreenshotModeCustomFilter.kt │ ├── debug/ │ │ └── AndroidManifest.xml │ ├── instrumented/ │ │ └── AndroidManifest.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── io/ │ │ │ └── github/ │ │ │ └── drumber/ │ │ │ └── kitsune/ │ │ │ ├── KitsuneApplication.kt │ │ │ ├── KitsuneGlideModule.kt │ │ │ ├── constants/ │ │ │ │ ├── AppTheme.kt │ │ │ │ ├── Defaults.kt │ │ │ │ ├── GitHub.kt │ │ │ │ ├── IntentAction.kt │ │ │ │ ├── Kitsu.kt │ │ │ │ ├── LibraryWidget.kt │ │ │ │ ├── MediaItemSize.kt │ │ │ │ ├── Repository.kt │ │ │ │ ├── SortFilter.kt │ │ │ │ └── StreamingLogo.kt │ │ │ ├── data/ │ │ │ │ ├── common/ │ │ │ │ │ ├── Filter.kt │ │ │ │ │ ├── Image.kt │ │ │ │ │ ├── Titles.kt │ │ │ │ │ ├── exception/ │ │ │ │ │ │ ├── InvalidDataException.kt │ │ │ │ │ │ ├── NoDataException.kt │ │ │ │ │ │ ├── NotFoundException.kt │ │ │ │ │ │ ├── ResourceUpdateFailed.kt │ │ │ │ │ │ └── SearchProviderUnavailableException.kt │ │ │ │ │ ├── library/ │ │ │ │ │ │ └── LibraryEntryKind.kt │ │ │ │ │ ├── media/ │ │ │ │ │ │ ├── AgeRating.kt │ │ │ │ │ │ ├── AnimeSubtype.kt │ │ │ │ │ │ ├── MangaSubtype.kt │ │ │ │ │ │ ├── MediaType.kt │ │ │ │ │ │ ├── RatingFrequencies.kt │ │ │ │ │ │ └── ReleaseStatus.kt │ │ │ │ │ └── user/ │ │ │ │ │ └── UserThemePreference.kt │ │ │ │ ├── mapper/ │ │ │ │ │ ├── AlgoliaMapper.kt │ │ │ │ │ ├── AppUpdateMapper.kt │ │ │ │ │ ├── AuthMapper.kt │ │ │ │ │ ├── CharacterMapper.kt │ │ │ │ │ ├── ImageMapper.kt │ │ │ │ │ ├── LibraryMapper.kt │ │ │ │ │ ├── MapperUtils.kt │ │ │ │ │ ├── MappingMapper.kt │ │ │ │ │ ├── MediaMapper.kt │ │ │ │ │ ├── MediaUnitMapper.kt │ │ │ │ │ ├── ProfileLinksMapper.kt │ │ │ │ │ ├── UserMapper.kt │ │ │ │ │ └── UserStatsMapper.kt │ │ │ │ ├── presentation/ │ │ │ │ │ ├── LocalRatingSystemPreference.kt │ │ │ │ │ ├── dto/ │ │ │ │ │ │ ├── CharacterDto.kt │ │ │ │ │ │ ├── ImageDto.kt │ │ │ │ │ │ ├── MediaDto.kt │ │ │ │ │ │ └── MediaUnitDto.kt │ │ │ │ │ └── model/ │ │ │ │ │ ├── algolia/ │ │ │ │ │ │ ├── AlgoliaKey.kt │ │ │ │ │ │ ├── AlgoliaKeyCollection.kt │ │ │ │ │ │ └── SearchType.kt │ │ │ │ │ ├── appupdate/ │ │ │ │ │ │ ├── AppRelease.kt │ │ │ │ │ │ └── UpdateCheckResult.kt │ │ │ │ │ ├── character/ │ │ │ │ │ │ ├── Character.kt │ │ │ │ │ │ ├── CharacterSearchResult.kt │ │ │ │ │ │ ├── MediaCharacter.kt │ │ │ │ │ │ └── MediaCharacterRole.kt │ │ │ │ │ ├── library/ │ │ │ │ │ │ ├── LibraryEntry.kt │ │ │ │ │ │ ├── LibraryEntryFilter.kt │ │ │ │ │ │ ├── LibraryEntryModification.kt │ │ │ │ │ │ ├── LibraryEntryUiModel.kt │ │ │ │ │ │ ├── LibraryEntryWithModification.kt │ │ │ │ │ │ ├── LibraryModificationState.kt │ │ │ │ │ │ ├── LibraryStatus.kt │ │ │ │ │ │ └── ReactionSkip.kt │ │ │ │ │ ├── mapping/ │ │ │ │ │ │ └── Mapping.kt │ │ │ │ │ ├── media/ │ │ │ │ │ │ ├── Anime.kt │ │ │ │ │ │ ├── Manga.kt │ │ │ │ │ │ ├── Media.kt │ │ │ │ │ │ ├── MediaSelector.kt │ │ │ │ │ │ ├── category/ │ │ │ │ │ │ │ ├── Category.kt │ │ │ │ │ │ │ └── CategoryNode.kt │ │ │ │ │ │ ├── production/ │ │ │ │ │ │ │ ├── AnimeProduction.kt │ │ │ │ │ │ │ ├── AnimeProductionRole.kt │ │ │ │ │ │ │ ├── Casting.kt │ │ │ │ │ │ │ ├── Person.kt │ │ │ │ │ │ │ └── Producer.kt │ │ │ │ │ │ ├── relationship/ │ │ │ │ │ │ │ ├── MediaRelationship.kt │ │ │ │ │ │ │ └── MediaRelationshipRole.kt │ │ │ │ │ │ ├── streamer/ │ │ │ │ │ │ │ ├── Streamer.kt │ │ │ │ │ │ │ └── StreamingLink.kt │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ ├── Chapter.kt │ │ │ │ │ │ ├── Episode.kt │ │ │ │ │ │ └── MediaUnit.kt │ │ │ │ │ └── user/ │ │ │ │ │ ├── Favorite.kt │ │ │ │ │ ├── User.kt │ │ │ │ │ ├── profilelinks/ │ │ │ │ │ │ ├── ProfileLink.kt │ │ │ │ │ │ └── ProfileLinkSite.kt │ │ │ │ │ └── stats/ │ │ │ │ │ ├── AmountConsumedPercentiles.kt │ │ │ │ │ ├── UserStats.kt │ │ │ │ │ ├── UserStatsData.kt │ │ │ │ │ └── UserStatsKind.kt │ │ │ │ ├── repository/ │ │ │ │ │ ├── AccessTokenRepository.kt │ │ │ │ │ ├── AlgoliaKeyRepository.kt │ │ │ │ │ ├── AnimeRepository.kt │ │ │ │ │ ├── AppUpdateRepository.kt │ │ │ │ │ ├── CastingRepository.kt │ │ │ │ │ ├── CategoryRepository.kt │ │ │ │ │ ├── CharacterRepository.kt │ │ │ │ │ ├── FavoriteRepository.kt │ │ │ │ │ ├── LibraryChangeListener.kt │ │ │ │ │ ├── LibraryEntryRemoteMediator.kt │ │ │ │ │ ├── LibraryRepository.kt │ │ │ │ │ ├── MangaRepository.kt │ │ │ │ │ ├── MappingRepository.kt │ │ │ │ │ ├── MediaUnitRepository.kt │ │ │ │ │ ├── ProfileLinkRepository.kt │ │ │ │ │ └── UserRepository.kt │ │ │ │ ├── source/ │ │ │ │ │ ├── local/ │ │ │ │ │ │ ├── LocalDatabase.kt │ │ │ │ │ │ ├── auth/ │ │ │ │ │ │ │ ├── AccessTokenLocalDataSource.kt │ │ │ │ │ │ │ ├── AccessTokenPreference.kt │ │ │ │ │ │ │ └── model/ │ │ │ │ │ │ │ └── LocalAccessToken.kt │ │ │ │ │ │ ├── character/ │ │ │ │ │ │ │ └── LocalCharacter.kt │ │ │ │ │ │ ├── library/ │ │ │ │ │ │ │ ├── LibraryLocalDataSource.kt │ │ │ │ │ │ │ ├── LocalLibraryConverters.kt │ │ │ │ │ │ │ ├── dao/ │ │ │ │ │ │ │ │ ├── LibraryEntryDao.kt │ │ │ │ │ │ │ │ ├── LibraryEntryModificationDao.kt │ │ │ │ │ │ │ │ ├── LibraryEntryWithModificationDao.kt │ │ │ │ │ │ │ │ └── RemoteKeyDao.kt │ │ │ │ │ │ │ └── model/ │ │ │ │ │ │ │ ├── LocalImage.kt │ │ │ │ │ │ │ ├── LocalLibraryEntry.kt │ │ │ │ │ │ │ ├── LocalLibraryEntryModification.kt │ │ │ │ │ │ │ ├── LocalLibraryEntryWithModification.kt │ │ │ │ │ │ │ ├── LocalLibraryMedia.kt │ │ │ │ │ │ │ ├── LocalLibraryModificationState.kt │ │ │ │ │ │ │ ├── LocalLibraryStatus.kt │ │ │ │ │ │ │ ├── LocalReactionSkip.kt │ │ │ │ │ │ │ └── RemoteKeyEntity.kt │ │ │ │ │ │ └── user/ │ │ │ │ │ │ ├── UserLocalDataSource.kt │ │ │ │ │ │ ├── UserPreferences.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── LocalRatingSystemPreference.kt │ │ │ │ │ │ ├── LocalSfwFilterPreference.kt │ │ │ │ │ │ ├── LocalTitleLanguagePreference.kt │ │ │ │ │ │ └── LocalUser.kt │ │ │ │ │ └── network/ │ │ │ │ │ ├── BasePagingDataSource.kt │ │ │ │ │ ├── PageData.kt │ │ │ │ │ ├── algolia/ │ │ │ │ │ │ ├── AlgoliaKeyNetworkDataSource.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── AlgoliaKeyApi.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── NetworkAlgoliaKey.kt │ │ │ │ │ │ ├── NetworkAlgoliaKeyCollection.kt │ │ │ │ │ │ └── search/ │ │ │ │ │ │ ├── AlgoliaCharacterSearchResult.kt │ │ │ │ │ │ ├── AlgoliaImage.kt │ │ │ │ │ │ ├── AlgoliaMediaSearchKind.kt │ │ │ │ │ │ └── AlgoliaMediaSearchResult.kt │ │ │ │ │ ├── appupdate/ │ │ │ │ │ │ ├── AppReleaseNetworkDataSource.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── GitHubApi.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ └── NetworkGitHubRelease.kt │ │ │ │ │ ├── auth/ │ │ │ │ │ │ ├── AccessTokenNetworkDataSource.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── AuthenticationApi.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── NetworkAccessToken.kt │ │ │ │ │ │ ├── ObtainAccessToken.kt │ │ │ │ │ │ └── RefreshAccessToken.kt │ │ │ │ │ ├── character/ │ │ │ │ │ │ ├── CharacterNetworkDataSource.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── CharacterApi.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── NetworkCharacter.kt │ │ │ │ │ │ ├── NetworkMediaCharacter.kt │ │ │ │ │ │ └── NetworkMediaCharacterRole.kt │ │ │ │ │ ├── library/ │ │ │ │ │ │ ├── LibraryEntryPagingDataSource.kt │ │ │ │ │ │ ├── LibraryNetworkDataSource.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── LibraryEntryApi.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── NetworkLibraryEntry.kt │ │ │ │ │ │ ├── NetworkLibraryStatus.kt │ │ │ │ │ │ └── NetworkReactionSkip.kt │ │ │ │ │ ├── mapping/ │ │ │ │ │ │ ├── MappingNetworkDataSource.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ └── MappingApi.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ └── NetworkMapping.kt │ │ │ │ │ ├── media/ │ │ │ │ │ │ ├── AnimeNetworkDataSource.kt │ │ │ │ │ │ ├── AnimePagingDataSource.kt │ │ │ │ │ │ ├── CastingNetworkDataSource.kt │ │ │ │ │ │ ├── CastingPagingDataSource.kt │ │ │ │ │ │ ├── CategoryNetworkDataSource.kt │ │ │ │ │ │ ├── ChapterNetworkDataSource.kt │ │ │ │ │ │ ├── ChapterPagingDataSource.kt │ │ │ │ │ │ ├── EpisodeNetworkDataSource.kt │ │ │ │ │ │ ├── EpisodePagingDataSource.kt │ │ │ │ │ │ ├── MangaNetworkDataSource.kt │ │ │ │ │ │ ├── MangaPagingDataSource.kt │ │ │ │ │ │ ├── TrendingAnimePagingDataSource.kt │ │ │ │ │ │ ├── TrendingMangaPagingDataSource.kt │ │ │ │ │ │ ├── api/ │ │ │ │ │ │ │ ├── AnimeApi.kt │ │ │ │ │ │ │ ├── CastingApi.kt │ │ │ │ │ │ │ ├── CategoryApi.kt │ │ │ │ │ │ │ ├── ChapterApi.kt │ │ │ │ │ │ │ ├── EpisodeApi.kt │ │ │ │ │ │ │ └── MangaApi.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── NetworkAnime.kt │ │ │ │ │ │ ├── NetworkAnimeSubtype.kt │ │ │ │ │ │ ├── NetworkManga.kt │ │ │ │ │ │ ├── NetworkMangaSubtype.kt │ │ │ │ │ │ ├── NetworkMedia.kt │ │ │ │ │ │ ├── NetworkRatingFrequencies.kt │ │ │ │ │ │ ├── NetworkReleaseStatus.kt │ │ │ │ │ │ ├── category/ │ │ │ │ │ │ │ └── NetworkCategory.kt │ │ │ │ │ │ ├── production/ │ │ │ │ │ │ │ ├── NetworkAnimeProduction.kt │ │ │ │ │ │ │ ├── NetworkAnimeProductionRole.kt │ │ │ │ │ │ │ ├── NetworkCasting.kt │ │ │ │ │ │ │ ├── NetworkPerson.kt │ │ │ │ │ │ │ └── NetworkProducer.kt │ │ │ │ │ │ ├── relationship/ │ │ │ │ │ │ │ ├── NetworkMediaRelationship.kt │ │ │ │ │ │ │ └── NetworkMediaRelationshipRole.kt │ │ │ │ │ │ ├── streamer/ │ │ │ │ │ │ │ ├── NetworkStreamer.kt │ │ │ │ │ │ │ └── NetworkStreamingLink.kt │ │ │ │ │ │ └── unit/ │ │ │ │ │ │ ├── NetworkChapter.kt │ │ │ │ │ │ ├── NetworkEpisode.kt │ │ │ │ │ │ └── NetworkMediaUnit.kt │ │ │ │ │ └── user/ │ │ │ │ │ ├── FavoriteNetworkDataSource.kt │ │ │ │ │ ├── ProfileLinkNetworkDataSource.kt │ │ │ │ │ ├── UserNetworkDataSource.kt │ │ │ │ │ ├── api/ │ │ │ │ │ │ ├── FavoriteApi.kt │ │ │ │ │ │ ├── ProfileLinkApi.kt │ │ │ │ │ │ ├── UserApi.kt │ │ │ │ │ │ └── UserImageUploadApi.kt │ │ │ │ │ └── model/ │ │ │ │ │ ├── NetworkFavorite.kt │ │ │ │ │ ├── NetworkRatingSystemPreference.kt │ │ │ │ │ ├── NetworkSfwFilterPreference.kt │ │ │ │ │ ├── NetworkTitleLanguagePreference.kt │ │ │ │ │ ├── NetworkUser.kt │ │ │ │ │ ├── NetworkUserImageUpload.kt │ │ │ │ │ ├── profilelinks/ │ │ │ │ │ │ ├── NetworkProfileLink.kt │ │ │ │ │ │ └── NetworkProfileLinkSite.kt │ │ │ │ │ └── stats/ │ │ │ │ │ ├── NetworkAmountConsumedPercentiles.kt │ │ │ │ │ ├── NetworkUserStats.kt │ │ │ │ │ ├── NetworkUserStatsData.kt │ │ │ │ │ └── NetworkUserStatsKind.kt │ │ │ │ └── utils/ │ │ │ │ └── InvalidatingPagingSourceFactory.kt │ │ │ ├── di/ │ │ │ │ ├── AppModule.kt │ │ │ │ ├── DataModule.kt │ │ │ │ ├── DatabaseModule.kt │ │ │ │ ├── DomainModule.kt │ │ │ │ ├── NetworkModule.kt │ │ │ │ └── ViewModelModule.kt │ │ │ ├── domain/ │ │ │ │ ├── algolia/ │ │ │ │ │ ├── FilterCollection.kt │ │ │ │ │ └── SearchProvider.kt │ │ │ │ ├── auth/ │ │ │ │ │ ├── IsUserLoggedInUseCase.kt │ │ │ │ │ ├── LogInUserUseCase.kt │ │ │ │ │ ├── LogOutUserUseCase.kt │ │ │ │ │ ├── LoginResult.kt │ │ │ │ │ ├── RefreshAccessTokenIfExpiredUseCase.kt │ │ │ │ │ ├── RefreshAccessTokenUseCase.kt │ │ │ │ │ └── RefreshResult.kt │ │ │ │ ├── library/ │ │ │ │ │ ├── FetchLibraryEntriesForWidgetUseCase.kt │ │ │ │ │ ├── GetLibraryEntriesWithModificationsPagerUseCase.kt │ │ │ │ │ ├── LibraryEntryUpdateResult.kt │ │ │ │ │ ├── SearchLibraryEntriesWithLocalModificationsPagerUseCase.kt │ │ │ │ │ ├── SynchronizeLocalLibraryModificationsUseCase.kt │ │ │ │ │ ├── UpdateLibraryEntryProgressUseCase.kt │ │ │ │ │ ├── UpdateLibraryEntryRatingUseCase.kt │ │ │ │ │ └── UpdateLibraryEntryUseCase.kt │ │ │ │ ├── user/ │ │ │ │ │ ├── GetLocalUserIdUseCase.kt │ │ │ │ │ └── UpdateLocalUserUseCase.kt │ │ │ │ └── work/ │ │ │ │ └── UpdateLibraryWidgetUseCase.kt │ │ │ ├── notification/ │ │ │ │ ├── NotificationChannels.kt │ │ │ │ └── Notifications.kt │ │ │ ├── preference/ │ │ │ │ ├── CategoryPrefWrapper.kt │ │ │ │ ├── KitsunePref.kt │ │ │ │ └── StartPagePref.kt │ │ │ ├── ui/ │ │ │ │ ├── adapter/ │ │ │ │ │ ├── AbstractMediaRecyclerViewAdapter.kt │ │ │ │ │ ├── CharacterAdapter.kt │ │ │ │ │ ├── MediaCharacterAdapter.kt │ │ │ │ │ ├── MediaMappingsAdapter.kt │ │ │ │ │ ├── MediaRecyclerViewAdapter.kt │ │ │ │ │ ├── MediaRelationshipRecyclerViewAdapter.kt │ │ │ │ │ ├── MediaRelationshipViewHolder.kt │ │ │ │ │ ├── MediaViewHolder.kt │ │ │ │ │ ├── OnItemClickListener.kt │ │ │ │ │ ├── StreamingLinkAdapter.kt │ │ │ │ │ └── paging/ │ │ │ │ │ ├── AnimeAdapter.kt │ │ │ │ │ ├── CharacterPagingAdapter.kt │ │ │ │ │ ├── LibraryEntriesAdapter.kt │ │ │ │ │ ├── MangaAdapter.kt │ │ │ │ │ ├── MediaPagingAdapter.kt │ │ │ │ │ ├── MediaSearchPagingAdapter.kt │ │ │ │ │ ├── MediaUnitPagingAdapter.kt │ │ │ │ │ └── ResourceLoadStateAdapter.kt │ │ │ │ ├── authentication/ │ │ │ │ │ ├── AuthenticationActivity.kt │ │ │ │ │ ├── LoggedInUserView.kt │ │ │ │ │ ├── LoginFormState.kt │ │ │ │ │ ├── LoginResultUi.kt │ │ │ │ │ └── LoginViewModel.kt │ │ │ │ ├── base/ │ │ │ │ │ ├── BaseActivity.kt │ │ │ │ │ ├── BaseDialogFragment.kt │ │ │ │ │ ├── BaseFragment.kt │ │ │ │ │ └── BasePreferenceFragment.kt │ │ │ │ ├── component/ │ │ │ │ │ ├── CustomNumberSpinner.kt │ │ │ │ │ ├── ExpandableLayout.kt │ │ │ │ │ ├── ExploreSection.kt │ │ │ │ │ ├── LayoutResourceLoadingLoadState.kt │ │ │ │ │ ├── LoadStateSpanSizeLookup.kt │ │ │ │ │ ├── MediaItemCard.kt │ │ │ │ │ ├── NestedScrollableHost.kt │ │ │ │ │ ├── PhotoViewNestedScrollView.kt │ │ │ │ │ ├── ResponsiveGridLayoutManager.kt │ │ │ │ │ ├── UniqueStateRecyclerView.kt │ │ │ │ │ ├── algolia/ │ │ │ │ │ │ ├── SeasonListPresenter.kt │ │ │ │ │ │ └── range/ │ │ │ │ │ │ ├── CustomFilterRangeConnectionFilterState.kt │ │ │ │ │ │ ├── CustomFilterRangeConnector.kt │ │ │ │ │ │ ├── CustomNumberRangeConnectionView.kt │ │ │ │ │ │ ├── CustomNumberRangeView.kt │ │ │ │ │ │ └── IntNumberRangeView.kt │ │ │ │ │ └── chart/ │ │ │ │ │ ├── BarChartStyle.kt │ │ │ │ │ ├── BaseChartStyle.kt │ │ │ │ │ ├── CustomPercentFormatter.kt │ │ │ │ │ ├── NonZeroLargeValueFormatter.kt │ │ │ │ │ ├── PieChartStyle.kt │ │ │ │ │ └── StepAxisValueFormatter.kt │ │ │ │ ├── details/ │ │ │ │ │ ├── DetailsFragment.kt │ │ │ │ │ ├── DetailsViewModel.kt │ │ │ │ │ ├── ManageLibraryBottomSheet.kt │ │ │ │ │ ├── MediaMappingsBottomSheet.kt │ │ │ │ │ ├── characters/ │ │ │ │ │ │ ├── CharacterDetailsBottomSheet.kt │ │ │ │ │ │ ├── CharacterDetailsViewModel.kt │ │ │ │ │ │ ├── CharacterFilterAdapter.kt │ │ │ │ │ │ ├── CharactersFragment.kt │ │ │ │ │ │ └── CharactersViewModel.kt │ │ │ │ │ └── episodes/ │ │ │ │ │ ├── EpisodesFragment.kt │ │ │ │ │ ├── EpisodesViewModel.kt │ │ │ │ │ └── MediaUnitDetailsBottomSheet.kt │ │ │ │ ├── library/ │ │ │ │ │ ├── LibraryFragment.kt │ │ │ │ │ ├── LibraryViewModel.kt │ │ │ │ │ ├── RatingBottomSheet.kt │ │ │ │ │ └── editentry/ │ │ │ │ │ ├── LibraryEditEntryFragment.kt │ │ │ │ │ └── LibraryEditEntryViewModel.kt │ │ │ │ ├── main/ │ │ │ │ │ ├── HomeExploreFragment.kt │ │ │ │ │ ├── HomeExploreViewPagerAdapter.kt │ │ │ │ │ ├── MainActivity.kt │ │ │ │ │ ├── MainActivityViewModel.kt │ │ │ │ │ ├── MainFragment.kt │ │ │ │ │ └── MainFragmentViewModel.kt │ │ │ │ ├── medialist/ │ │ │ │ │ ├── MediaListFragment.kt │ │ │ │ │ └── MediaListViewModel.kt │ │ │ │ ├── onboarding/ │ │ │ │ │ ├── OnboardingActivity.kt │ │ │ │ │ ├── OnboardingUiState.kt │ │ │ │ │ ├── OnboardingViewModel.kt │ │ │ │ │ ├── components/ │ │ │ │ │ │ ├── CustomDialog.kt │ │ │ │ │ │ ├── ImagePresenter.kt │ │ │ │ │ │ ├── ImageSlideshow.kt │ │ │ │ │ │ ├── OnboardingNavigationControls.kt │ │ │ │ │ │ └── PreferenceCard.kt │ │ │ │ │ └── pages/ │ │ │ │ │ ├── LoginPage.kt │ │ │ │ │ ├── SetupPage.kt │ │ │ │ │ └── WelcomePage.kt │ │ │ │ ├── permissions/ │ │ │ │ │ └── NotificationPermission.kt │ │ │ │ ├── photoview/ │ │ │ │ │ └── PhotoViewActivity.kt │ │ │ │ ├── profile/ │ │ │ │ │ ├── ProfileFragment.kt │ │ │ │ │ ├── ProfileStatsAdapter.kt │ │ │ │ │ ├── ProfileViewModel.kt │ │ │ │ │ └── editprofile/ │ │ │ │ │ ├── CharacterSearchResultAdapter.kt │ │ │ │ │ ├── EditProfileFragment.kt │ │ │ │ │ ├── EditProfileLinkBottomSheet.kt │ │ │ │ │ ├── EditProfileViewModel.kt │ │ │ │ │ └── SelectProfileLinkSiteBottomSheet.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── SearchFragment.kt │ │ │ │ │ ├── SearchViewModel.kt │ │ │ │ │ ├── categories/ │ │ │ │ │ │ ├── CategoriesDialogFragment.kt │ │ │ │ │ │ ├── CategoriesViewModel.kt │ │ │ │ │ │ └── CategoryViewHolder.kt │ │ │ │ │ └── filter/ │ │ │ │ │ ├── FacetFragment.kt │ │ │ │ │ └── FilterFacetListViewHolder.kt │ │ │ │ ├── settings/ │ │ │ │ │ ├── AppLogsFragment.kt │ │ │ │ │ ├── AppLogsViewModel.kt │ │ │ │ │ ├── AppearanceFragment.kt │ │ │ │ │ ├── OSLibrariesFragment.kt │ │ │ │ │ ├── SettingsFragment.kt │ │ │ │ │ ├── SettingsViewModel.kt │ │ │ │ │ └── ThemePickerPreference.kt │ │ │ │ ├── theme/ │ │ │ │ │ ├── MdcThemeAdapter.kt │ │ │ │ │ └── Theme.kt │ │ │ │ ├── webview/ │ │ │ │ │ └── WebViewFragment.kt │ │ │ │ └── widget/ │ │ │ │ ├── KitsuneWidgetReceiver.kt │ │ │ │ ├── KitsuneWidgetTheme.kt │ │ │ │ ├── LibraryAppWidget.kt │ │ │ │ └── WidgetUtils.kt │ │ │ ├── util/ │ │ │ │ ├── DataUtil.kt │ │ │ │ ├── DateUtil.kt │ │ │ │ ├── ItemClickListener.kt │ │ │ │ ├── KitsuUrlReplacer.kt │ │ │ │ ├── LogCatReader.kt │ │ │ │ ├── LogUtil.kt │ │ │ │ ├── SaveImage.kt │ │ │ │ ├── TimeUtil.kt │ │ │ │ ├── extensions/ │ │ │ │ │ ├── ActivityExtensions.kt │ │ │ │ │ ├── FragmentExtensions.kt │ │ │ │ │ └── OtherExtensions.kt │ │ │ │ ├── json/ │ │ │ │ │ ├── AlgoliaFacetValueDeserializer.kt │ │ │ │ │ ├── AlgoliaNumericValueDeserializer.kt │ │ │ │ │ ├── IgnoreParcelablePropertyMixin.kt │ │ │ │ │ └── NullableIntSerializer.kt │ │ │ │ ├── network/ │ │ │ │ │ ├── AuthenticationInterceptor.kt │ │ │ │ │ ├── ResponseData.kt │ │ │ │ │ └── UserAgentInterceptor.kt │ │ │ │ ├── rating/ │ │ │ │ │ ├── RatingFrequenciesUtil.kt │ │ │ │ │ └── RatingSystemUtil.kt │ │ │ │ └── ui/ │ │ │ │ ├── BindingAdapter.kt │ │ │ │ ├── DateValidatorPointBetween.kt │ │ │ │ ├── ProfileSiteLogo.kt │ │ │ │ ├── RoundBitmapDrawable.kt │ │ │ │ ├── SnackbarUtils.kt │ │ │ │ └── WindowInsetsUtil.kt │ │ │ └── work/ │ │ │ ├── SyncLibraryEntriesForWidgetWorker.kt │ │ │ └── UpdateLibraryWidgetWorker.kt │ │ └── res/ │ │ ├── anim/ │ │ │ ├── slide_down.xml │ │ │ └── slide_up.xml │ │ ├── animator/ │ │ │ ├── scale_enter_anim.xml │ │ │ ├── scale_exit_anim.xml │ │ │ ├── scale_pop_enter_anim.xml │ │ │ └── scale_pop_exit_anim.xml │ │ ├── color/ │ │ │ ├── subtype_badge_background.xml │ │ │ ├── translucent_overlay.xml │ │ │ └── translucent_status_bar.xml │ │ ├── drawable/ │ │ │ ├── animated_favorite.xml │ │ │ ├── badge_background.xml │ │ │ ├── bottom_edge_fade.xml │ │ │ ├── bottom_edge_fade_surface.xml │ │ │ ├── cover_placeholder.xml │ │ │ ├── explore_section_divider.xml │ │ │ ├── ic_add_24.xml │ │ │ ├── ic_add_a_photo_24.xml │ │ │ ├── ic_amazon.xml │ │ │ ├── ic_animelab.xml │ │ │ ├── ic_arrow_back_24.xml │ │ │ ├── ic_arrow_drop_down_24.xml │ │ │ ├── ic_arrow_forward_24.xml │ │ │ ├── ic_bar_chart_16.xml │ │ │ ├── ic_battle_net.xml │ │ │ ├── ic_bookmark_added_24.xml │ │ │ ├── ic_cake_24.xml │ │ │ ├── ic_calendar_24.xml │ │ │ ├── ic_calendar_month_24.xml │ │ │ ├── ic_cancel_presentation_24.xml │ │ │ ├── ic_check_24.xml │ │ │ ├── ic_close_24.xml │ │ │ ├── ic_cloud_off_16.xml │ │ │ ├── ic_code_24.xml │ │ │ ├── ic_contv.xml │ │ │ ├── ic_crunchyroll.xml │ │ │ ├── ic_dailymotion.xml │ │ │ ├── ic_delete_24.xml │ │ │ ├── ic_deviantart.xml │ │ │ ├── ic_discord.xml │ │ │ ├── ic_done_24.xml │ │ │ ├── ic_dribbble.xml │ │ │ ├── ic_edit_24.xml │ │ │ ├── ic_emoji_events_24.xml │ │ │ ├── ic_facebook.xml │ │ │ ├── ic_favorite_24.xml │ │ │ ├── ic_favorite_border_24.xml │ │ │ ├── ic_filter_24.xml │ │ │ ├── ic_funimation.xml │ │ │ ├── ic_github.xml │ │ │ ├── ic_google_plus.xml │ │ │ ├── ic_heart_broken_24.xml │ │ │ ├── ic_hidive.xml │ │ │ ├── ic_home_24.xml │ │ │ ├── ic_hulu.xml │ │ │ ├── ic_imdb.xml │ │ │ ├── ic_incomplete_circle_24.xml │ │ │ ├── ic_insert_photo_48.xml │ │ │ ├── ic_instagram.xml │ │ │ ├── ic_keyboard_arrow_down_24.xml │ │ │ ├── ic_kickstarter.xml │ │ │ ├── ic_lastfm.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_letterboxd.xml │ │ │ ├── ic_location_24.xml │ │ │ ├── ic_medium.xml │ │ │ ├── ic_mobcrush.xml │ │ │ ├── ic_more_vert.xml │ │ │ ├── ic_netflix.xml │ │ │ ├── ic_notification_icon.xml │ │ │ ├── ic_open_in_browser_24.xml │ │ │ ├── ic_open_in_new_24.xml │ │ │ ├── ic_osu.xml │ │ │ ├── ic_outline_bookmarks_24.xml │ │ │ ├── ic_outline_explore_24.xml │ │ │ ├── ic_outline_home_24.xml │ │ │ ├── ic_outline_info_24.xml │ │ │ ├── ic_outline_person_24.xml │ │ │ ├── ic_outline_view_list_24.xml │ │ │ ├── ic_patreon.xml │ │ │ ├── ic_person_24.xml │ │ │ ├── ic_player_me.xml │ │ │ ├── ic_raptr.xml │ │ │ ├── ic_reddit.xml │ │ │ ├── ic_remove_24.xml │ │ │ ├── ic_restore_24.xml │ │ │ ├── ic_save_24.xml │ │ │ ├── ic_save_alt_24.xml │ │ │ ├── ic_schedule_24.xml │ │ │ ├── ic_search_24.xml │ │ │ ├── ic_settings_24.xml │ │ │ ├── ic_share_24.xml │ │ │ ├── ic_shortcut_library_24.xml │ │ │ ├── ic_shortcut_search_24.xml │ │ │ ├── ic_shortcut_settings_24.xml │ │ │ ├── ic_soundcloud.xml │ │ │ ├── ic_splashscreen.xml │ │ │ ├── ic_star_24.xml │ │ │ ├── ic_star_outline_24.xml │ │ │ ├── ic_steam.xml │ │ │ ├── ic_sync_24.xml │ │ │ ├── ic_trakt.xml │ │ │ ├── ic_tubitv.xml │ │ │ ├── ic_tumblr.xml │ │ │ ├── ic_twitch.xml │ │ │ ├── ic_twitter.xml │ │ │ ├── ic_view_list_24.xml │ │ │ ├── ic_vimeo.xml │ │ │ ├── ic_vrv.xml │ │ │ ├── ic_watch_later_24.xml │ │ │ ├── ic_website.xml │ │ │ ├── ic_youtube.xml │ │ │ ├── onboarding_login_logo.xml │ │ │ ├── profile_picture_placeholder.xml │ │ │ ├── progress_horizontal.xml │ │ │ ├── radial_edge_fade.xml │ │ │ ├── rectangle_background.xml │ │ │ ├── selectable_item_background.xml │ │ │ ├── selector_home.xml │ │ │ ├── selector_library.xml │ │ │ ├── selector_profile.xml │ │ │ ├── selector_search.xml │ │ │ ├── top_edge_fade.xml │ │ │ ├── top_edge_fade_surface.xml │ │ │ ├── translucent_background.xml │ │ │ └── widget_rounded_rect.xml │ │ ├── drawable-night/ │ │ │ └── progress_horizontal.xml │ │ ├── layout/ │ │ │ ├── activity_authentication.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_photo_view.xml │ │ │ ├── custom_edit_text_preference.xml │ │ │ ├── custom_number_spinner.xml │ │ │ ├── custom_preference_switch.xml │ │ │ ├── fragment_app_logs.xml │ │ │ ├── fragment_categories.xml │ │ │ ├── fragment_characters.xml │ │ │ ├── fragment_details.xml │ │ │ ├── fragment_edit_library_entry.xml │ │ │ ├── fragment_edit_profile.xml │ │ │ ├── fragment_filter_facet.xml │ │ │ ├── fragment_home_explore.xml │ │ │ ├── fragment_library.xml │ │ │ ├── fragment_main.xml │ │ │ ├── fragment_media_list.xml │ │ │ ├── fragment_os_libraries.xml │ │ │ ├── fragment_preference.xml │ │ │ ├── fragment_profile.xml │ │ │ ├── fragment_search.xml │ │ │ ├── fragment_web_view.xml │ │ │ ├── item_category_node.xml │ │ │ ├── item_character.xml │ │ │ ├── item_character_filter.xml │ │ │ ├── item_character_search_result.xml │ │ │ ├── item_details_info_row.xml │ │ │ ├── item_dropdown.xml │ │ │ ├── item_episode.xml │ │ │ ├── item_facet.xml │ │ │ ├── item_library_entry.xml │ │ │ ├── item_library_status_separator.xml │ │ │ ├── item_list_option.xml │ │ │ ├── item_media.xml │ │ │ ├── item_media_mapping.xml │ │ │ ├── item_network_state.xml │ │ │ ├── item_profile_site_chip.xml │ │ │ ├── item_profile_stats.xml │ │ │ ├── item_single_character.xml │ │ │ ├── item_streamer.xml │ │ │ ├── item_theme_option.xml │ │ │ ├── layout_resource_loading.xml │ │ │ ├── layout_search_provider_loading.xml │ │ │ ├── layout_theme_picker_preference.xml │ │ │ ├── section_details_description.xml │ │ │ ├── section_details_info.xml │ │ │ ├── section_details_stats.xml │ │ │ ├── section_details_trailer.xml │ │ │ ├── section_main_explore.xml │ │ │ ├── sheet_character_details.xml │ │ │ ├── sheet_edit_profile_link.xml │ │ │ ├── sheet_library_rating.xml │ │ │ ├── sheet_manage_library.xml │ │ │ ├── sheet_media_mappings.xml │ │ │ ├── sheet_media_unit_details.xml │ │ │ └── sheet_select_profile_link_site.xml │ │ ├── layout-w600dp/ │ │ │ └── activity_main.xml │ │ ├── menu/ │ │ │ ├── category_dialog_menu.xml │ │ │ ├── details_menu.xml │ │ │ ├── filter_facet_menu.xml │ │ │ ├── library_menu.xml │ │ │ ├── logs_menu.xml │ │ │ ├── main_nav_menu.xml │ │ │ ├── profile_menu.xml │ │ │ └── webview_menu.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── navigation/ │ │ │ ├── main_nav_graph.xml │ │ │ └── settings_nav_graph.xml │ │ ├── resources.properties │ │ ├── values/ │ │ │ ├── MdcThemeAdapter.xml │ │ │ ├── arrays.xml │ │ │ ├── attr.xml │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── integers.xml │ │ │ ├── preference_keys.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ └── themes.xml │ │ ├── values-af/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-land/ │ │ │ └── dimens.xml │ │ ├── values-night/ │ │ │ ├── colors.xml │ │ │ └── themes.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-ta/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-v31/ │ │ │ └── styles.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-w1240dp/ │ │ │ └── dimens.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ ├── xml/ │ │ │ ├── app_preferences.xml │ │ │ ├── appearance_preferences.xml │ │ │ ├── backup_rules.xml │ │ │ ├── backup_rules_sdk31.xml │ │ │ ├── filepaths.xml │ │ │ └── library_widget_info.xml │ │ └── xml-v25/ │ │ └── shortcuts.xml │ └── test/ │ ├── java/ │ │ └── io/ │ │ └── github/ │ │ └── drumber/ │ │ └── kitsune/ │ │ ├── archunit/ │ │ │ └── ArchUnitTest.kt │ │ ├── data/ │ │ │ ├── mapper/ │ │ │ │ ├── AuthMapperTest.kt │ │ │ │ ├── CharacterMapperTest.kt │ │ │ │ ├── MediaMapperTest.kt │ │ │ │ └── UserMapperTest.kt │ │ │ ├── presentation/ │ │ │ │ └── model/ │ │ │ │ ├── library/ │ │ │ │ │ ├── LibraryEntryModificationTest.kt │ │ │ │ │ └── LibraryEntryWithModificationTest.kt │ │ │ │ └── media/ │ │ │ │ └── MediaTest.kt │ │ │ ├── repository/ │ │ │ │ ├── AccessTokenRepositoryTest.kt │ │ │ │ ├── AlgoliaKeyRepositoryTest.kt │ │ │ │ ├── AppUpdateRepositoryTest.kt │ │ │ │ ├── LibraryRepositoryTest.kt │ │ │ │ └── UserRepositoryTest.kt │ │ │ └── source/ │ │ │ ├── local/ │ │ │ │ ├── auth/ │ │ │ │ │ └── LocalAccessTokenTest.kt │ │ │ │ └── library/ │ │ │ │ └── model/ │ │ │ │ └── LocalLibraryEntryModificationTest.kt │ │ │ └── network/ │ │ │ └── PageDataTest.kt │ │ ├── domain/ │ │ │ ├── auth/ │ │ │ │ ├── LogInUserUseCaseTest.kt │ │ │ │ ├── LogOutUserUseCaseTest.kt │ │ │ │ ├── RefreshAccessTokenIfExpiredUseCaseTest.kt │ │ │ │ └── RefreshAccessTokenUseCaseTest.kt │ │ │ ├── library/ │ │ │ │ └── SynchronizeLocalLibraryModificationsUseCaseTest.kt │ │ │ └── user/ │ │ │ └── UpdateLocalUserUseCaseTest.kt │ │ ├── testutils/ │ │ │ ├── AndroidLoggerMock.kt │ │ │ ├── FakeCharacter.kt │ │ │ ├── FakeImage.kt │ │ │ ├── FakeLibraryEntry.kt │ │ │ ├── FakeLibraryEntryModification.kt │ │ │ ├── FakeMedia.kt │ │ │ ├── FakeUser.kt │ │ │ ├── KoinTestModules.kt │ │ │ ├── SuspendFunctionHelper.kt │ │ │ └── network/ │ │ │ ├── FakeHttpException.kt │ │ │ └── NoOpAuthenticationInterceptor.kt │ │ └── util/ │ │ ├── DateUtilTest.kt │ │ └── rating/ │ │ └── RatingFrequenciesUtilTest.kt │ └── resources/ │ └── mockito-extensions/ │ └── org.mockito.plugins.MockMaker ├── build.gradle.kts ├── docs/ │ └── hidden-actions.md ├── fastlane/ │ ├── Appfile │ ├── Fastfile │ ├── README.md │ ├── Workflow.md │ └── metadata/ │ └── android/ │ ├── en-US/ │ │ ├── changelogs/ │ │ │ ├── 25.txt │ │ │ ├── 26.txt │ │ │ ├── 27.txt │ │ │ ├── 28.txt │ │ │ ├── 29.txt │ │ │ ├── 30.txt │ │ │ ├── 31.txt │ │ │ ├── 32.txt │ │ │ ├── 33.txt │ │ │ ├── 34.txt │ │ │ ├── 35.txt │ │ │ ├── 36.txt │ │ │ ├── 37.txt │ │ │ ├── 38.txt │ │ │ └── 39.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ ├── title.txt │ │ └── video.txt │ └── zh-CN/ │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── plugin/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── settings.gradle.kts │ └── src/ │ └── main/ │ └── java/ │ └── io/ │ └── github/ │ └── drumber/ │ └── plugin/ │ ├── CustomPlugin.kt │ ├── ExtractLocalesTask.kt │ └── ReplaceShortcutsPackageTask.kt └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug Report description: Create a report for bugs or other issues you've encountered. labels: [ bug ] body: - type: textarea id: summary attributes: label: Problem description description: A clear and concise description of the issue. validations: required: true - type: textarea id: reproduce-steps attributes: label: Steps to reproduce description: Describe the steps to reproduce the issue. placeholder: | Example: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' - type: input id: app-version attributes: label: App version description: The installed app version (Settings > About). placeholder: 1.0.0 validations: required: true - type: input id: android-version attributes: label: Android version description: The Android version of your device. placeholder: "10" validations: required: true - type: input id: device attributes: label: Device information description: | Optional: The device model you're using. placeholder: e.g. Google Pixel 4 - type: textarea id: attachments attributes: label: Attachments and Logs description: | If applicable, add screenshots or screen recordings to help explain the issue. You can also paste or upload logs here (you can find them in Settings > Application Logs). - type: checkboxes id: acknowledgements attributes: label: Acknowledgements options: - label: I have searched the open and closed [issues](https://github.com/Drumber/Kitsune/issues?q=is%3Aissue) and this is **NOT** a duplicate. required: true - label: I'm using the latest version of the app. required: true - label: I have provided all required information. required: true ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Suggest new features or improvements for the app. labels: [ enhancement ] body: - type: textarea id: description attributes: label: Description description: Describe the feature you'd like to see. validations: required: true - type: checkboxes id: acknowledgements attributes: label: Acknowledgements options: - label: I have searched the open and closed [issues](https://github.com/Drumber/Kitsune/issues?q=is%3Aissue) and this is **NOT** a duplicate. required: true ================================================ FILE: .github/workflows/build.yml ================================================ name: Build app on: workflow_dispatch: inputs: buildVariant: description: 'App build variant' required: false default: 'release' workflow_call: permissions: contents: read jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - name: Set gradle build task run: | if [ -z "${{ github.event.inputs.buildVariant }}" ]; then echo "build_task='assembleRelease'" >> "$GITHUB_ENV" else echo "build_task=assemble${{ github.event.inputs.buildVariant }}" >> "$GITHUB_ENV" fi - name: Build with Gradle uses: gradle/gradle-build-action@v2 with: arguments: ${{ env.build_task }} - name: Copy apk files run: mkdir -p ./artifacts && cp app/build/outputs/apk/**/*.apk ./artifacts/ - name: Upload apk uses: actions/upload-artifact@v4 with: name: app-unsigned path: artifacts/*.apk if-no-files-found: error ================================================ FILE: .github/workflows/reproducible-build.yml ================================================ name: Verify reproducible build on: workflow_dispatch: inputs: releaseTag: description: Tag of the release to download required: true release: types: [ published ] permissions: contents: write jobs: build: uses: ./.github/workflows/build.yml verify: needs: [ build ] runs-on: ubuntu-latest steps: - name: Install dependencies run: sudo apt-get update && sudo apt-get install apksigner python3-click apksigcopier -y - name: Download build artifact uses: actions/download-artifact@v4 with: name: app-unsigned - run: mv *.apk unsigned.apk - name: Set asset URL id: set_asset_url run: | if [ "${{ github.event_name }}" = "release" ]; then echo "release_tag=${{ github.event.release.tag_name }}" >> "$GITHUB_ENV" else echo "release_tag=${{ github.event.inputs.releaseTag }}" >> "$GITHUB_ENV" fi - name: Download release asset run: | gh release download "$release_tag" --pattern "*.apk" --output upstream.apk --repo "$REPO" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} - name: Compare APKs run: apksigcopier compare upstream.apk --unsigned unsigned.apk && echo OK ================================================ FILE: .github/workflows/test.yml ================================================ name: Test app on: [push, pull_request] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up JDK uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - name: Test with Gradle uses: gradle/gradle-build-action@v2 with: arguments: testDebugUnitTest ================================================ FILE: .gitignore ================================================ # Built application files *.apk *.aar *.ap_ *.aab # Files for the ART/Dalvik VM *.dex # Java class files *.class # Generated files bin/ gen/ out/ # Uncomment the following line in case you need and you don't have the release build type files in your app # release/ # Gradle files .gradle/ build/ # Local configuration file (sdk path, etc) local.properties # Proguard folder generated by Eclipse proguard/ # Log Files *.log # Android Studio Navigation editor temp files .navigation/ # Android Studio captures folder captures/ # IntelliJ *.iml .idea/workspace.xml .idea/tasks.xml .idea/gradle.xml .idea/assetWizardSettings.xml .idea/dictionaries .idea/libraries .idea/jarRepositories.xml .idea/deploymentTargetDropDown.xml .idea/misc.xml # Android Studio 3 in .gitignore file. .idea/caches .idea/modules.xml # Comment next line if keeping position of elements in Navigation Editor is relevant for you .idea/navEditor.xml # Keystore files # Uncomment the following lines if you do not want to check your keystore files in. #*.jks #*.keystore # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild .cxx/ # Google Services (e.g. APIs or Firebase) # google-services.json # Freeline freeline.py freeline/ freeline_project_description.json # fastlane fastlane/report.xml fastlane/Preview.html fastlane/screenshots fastlane/test_output fastlane/readme.md # Ruby bundler /.bundle/ # Version control vcs.xml # lint lint/intermediates/ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ # Android Profiling *.hprof ================================================ FILE: .idea/.gitignore ================================================ # Default ignored files /shelf/ /workspace.xml /androidTestResultsUserPreferences.xml /deploymentTargetSelector.xml /inspectionProfiles/Project_Default.xml /runConfigurations.xml /studiobot.xml /deviceManager.xml ================================================ FILE: .idea/AndroidProjectSystem.xml ================================================ ================================================ FILE: .idea/codeStyles/Project.xml ================================================ ================================================ FILE: .idea/codeStyles/codeStyleConfig.xml ================================================ ================================================ FILE: .idea/compiler.xml ================================================ ================================================ FILE: .idea/kotlinc.xml ================================================ ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the project team by [opening an new issue](https://github.com/Drumber/Kitsune/issues/new/choose). All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Kitsune Thank you for your interest in contributing to Kitsune! Any support is greatly appreciated. ## Ways to Contribute Any contributions are welcomed, including but not limited to: ### 1. Code Contributions - Check out the [open issues](https://github.com/Drumber/Kitsune/issues) or the [project board](https://github.com/users/Drumber/projects/2) and pick one to work on. - See [How to Contribute Code Changes](#how-to-contribute-code-changes) to get started. - Submit a pull request with your changes. ### 2. Image assets and Logos Interested in making Kitsune more visually appealing? Don't hesitate to open a [new issue](https://github.com/Drumber/Kitsune/issues/new/choose) or [discussion](https://github.com/Drumber/Kitsune/discussions/new/choose) if you want to create: - custom placeholder images for media and user banners, posters or character photos - a new app icon - or any other design contribution. ### 3. Translation - Contribute translations for different languages to make the app accessible globally. - Copy the [string.xml](app/src/main/res/values/strings.xml) file to a new folder named `values-LANG` (e.g. `values-fr` for French) inside the [res](app/src/main/res) directory. - Remove strings with `translatable="false"`. - Keep placeholders like `%s` or `%d` in the strings. - Submit a pull request with your changes. ### 4. Documentation and Repository Files - Improve the documentation or contribute to other repository files, like issue templates. ## How to Contribute Code Changes To start developing, follow these simple steps: 1. **Set Up Your Environment:** - Make sure you have [Android Studio](https://developer.android.com/studio) installed. - Familiarize yourself with [Kotlin](https://kotlinlang.org/) as it's the primary language used in the app. 2. **Clone the Repository** 3. **Build and Run:** - Open the project in Android Studio. - Build and run the app to ensure everything is set up correctly. ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" gem "fastlane" ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================

Kitsune

Unofficial android app for [Kitsu](https://kitsu.app). Discover new anime and manga and manage your library. ## Features - Explore and search anime and manga, even without an account - View anime and manga details including episodes/chapters and characters - Manage your Kitsu library and account settings - Cached library for offline use - Multiple dark and light app themes - Material 3 Design - Home screen widget #### Missing features - Reactions/Comments - Global message feed and announcements - Groups - Search for other users ## Download > Requires Android 8.0 or higher. Kitsune is available for download on GitHub and F-Droid. [Download latest app release on GitHub.](https://github.com/Drumber/Kitsune/releases/latest) [Get it on F-Droid](https://f-droid.org/packages/io.github.drumber.kitsune/) ## Localization Are you interested in translating Kitsune into your language? Head over to the Kitsune project on [Hosted Weblate](https://hosted.weblate.org/engage/kitsune/) and help localize the app. [![Translation status](https://hosted.weblate.org/widget/kitsune/multi-auto.svg)](https://hosted.weblate.org/engage/kitsune/) ## Bug reports, Feature requests and Contribution > Please be aware of the [Code of Conduct](CODE_OF_CONDUCT.md) in place. **Report a bug or request a new feature** - Please check out [existing issues](https://github.com/Drumber/Kitsune/issues?q=is%3Aissue) first to avoid duplicates. - [Open a new issue](https://github.com/Drumber/Kitsune/issues/new/choose) **Contribute to Kitsune** - See [Contributing](CONTRIBUTING.md) for more details. ## Screenshots ================================================ FILE: app/.gitignore ================================================ /build /release ================================================ FILE: app/build.gradle.kts ================================================ import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.KotlinVersion plugins { alias(libs.plugins.android.application) alias(libs.plugins.compose.compiler) alias(libs.plugins.android.legacy.kapt) alias(libs.plugins.ksp) alias(libs.plugins.androidx.navigation.safeargs) alias(libs.plugins.aboutlibraries.plugin) alias(libs.plugins.jetbrains.kotlin.parcelize) alias(libs.plugins.jetbrains.kotlin.serialization) id("kitsune-plugin") } val screenshotMode: String by project android { namespace = "io.github.drumber.kitsune" compileSdk = 36 buildToolsVersion = "36.0.0" defaultConfig { applicationId = "io.github.drumber.kitsune" minSdk = 26 targetSdk = 35 versionCode = 39 versionName = "2.0.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("boolean", "SCREENSHOT_MODE_ENABLED", screenshotMode) buildConfigField("boolean", "INSTRUMENTED_TEST", "false") } androidResources { generateLocaleConfig = true } buildTypes { getByName("release") { isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) signingConfig = signingConfigs.getByName("debug") vcsInfo.include = false } getByName("debug") { applicationIdSuffix = ".debug" versionNameSuffix = "-debug" isDebuggable = true } create("instrumented") { initWith(getByName("debug")) applicationIdSuffix = ".instrumented" buildConfigField("boolean", "INSTRUMENTED_TEST", "true") } } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } buildFeatures { viewBinding = true dataBinding = true buildConfig = true compose = true } packaging { resources.excludes += "META-INF/*.kotlin_module" } dependenciesInfo { includeInApk = false includeInBundle = false } testOptions { animationsDisabled = true testBuildType = "instrumented" } } kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_11 languageVersion = KotlinVersion.KOTLIN_2_2 freeCompilerArgs.add("-opt-in=kotlin.RequiresOptIn") } } ksp { arg("room.schemaLocation", "$projectDir/schemas") } aboutLibraries { offlineMode = true // Remove the "generated" timestamp to allow for reproducible builds excludeFields = arrayOf("generated") } dependencies { // Android core and support libs implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.constraint.layout) implementation(libs.androidx.core.splashscreen) // Compose val composeBom = platform(libs.androidx.compose.bom) implementation(composeBom) implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.accompanist.themeadapter.material3) implementation(libs.accompanist.permissions) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) // SwipeRefresh layout implementation(libs.androidx.swiperefreshlayout) // Navigation implementation(libs.androidx.navigation.fragment.ktx) implementation(libs.androidx.navigation.ui.ktx) implementation(libs.androidx.fragment.ktx) // Preference implementation(libs.androidx.preference.ktx) // Lifecycle implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.livedata.ktx) // WorkManager implementation(libs.androidx.workmanager) // Material implementation(libs.google.android.material) // Glance AppWidget implementation(libs.androidx.glance.appwidget) implementation(libs.androidx.glance.material3) implementation(libs.androidx.glance.preview) // Kotlin coroutines implementation(libs.jetbrains.kotlinx.coroutines.core) implementation(libs.jetbrains.kotlinx.coroutines.android) // Paging implementation(libs.androidx.paging.runtime.ktx) // Room implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) ksp(libs.androidx.room.compiler) implementation(libs.androidx.room.paging) // ViewPager implementation(libs.androidx.viewpager2) // Glide implementation(libs.bumptech.glide) ksp(libs.bumptech.glide.ksp) implementation(libs.bumptech.glide.okhttp3) implementation(libs.bumptech.glide.compose) // Koin DI implementation(libs.insert.koin.android) implementation(libs.insert.koin.androidx.navigation) // jsonapi-converter implementation(libs.jasminb.jsonapi) // Jackson implementation(libs.fasterxml.jackson.databind) implementation(libs.fasterxml.jackson.kotlin) // Retrofit implementation(libs.squareup.retrofit2.retrofit) implementation(libs.squareup.retrofit2.jackson) // OkHttp implementation(libs.squareup.okhttp3.okhttp) implementation(libs.squareup.okhttp3.logging) // Algolia Instantsearch implementation(libs.algolia.instantsearch.android) implementation(libs.algolia.instantsearch.android.paging3) implementation(libs.algolia.instantsearch.coroutines) // Kotlinx serialization implementation(libs.jetbrains.kotlinx.serialization) // Ktor client implementation(libs.ktor.client.okhttp) // Kotpref implementation(libs.chibatching.kotpref) implementation(libs.chibatching.kotpref.enum) implementation(libs.chibatching.kotpref.livedata) // Security Crypto implementation(libs.androidx.security.crypto) // TreeView implementation(libs.bmelnychuk.treeview) // Expandable text view implementation(libs.blogc.expandabletextview) // CircleImageView implementation(libs.hdodenhof.circleimageview) // Material Rating Bar implementation(libs.zhanghai.materialratingbar) // MPAndroidCharts implementation(libs.philjay.mpandroidchart) // Photo View implementation(libs.chrisbanes.photoview) // Hauler Gesture implementation(libs.futured.hauler) implementation(libs.futured.hauler.databinding) // AboutLibraries implementation(libs.mikepenz.aboutlibraries.core) implementation(libs.mikepenz.aboutlibraries) // LeakCanary debugImplementation(libs.squareup.leakcanary) // Glide Transformations (only used for demo screenshots) if (screenshotMode.toBoolean()) { implementation(libs.wasabeef.glide.transformations) } // Tests testImplementation(libs.junit) testImplementation(libs.assertj.core) testImplementation(libs.tngtech.archunit.junit4) testImplementation(libs.robolectric) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit.ktx) androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.contrib) // Compose tests androidTestImplementation(composeBom) androidTestImplementation(libs.androidx.compose.ui.test) debugImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(libs.jetbrains.kotlinx.coroutines.test) testImplementation(libs.insert.koin.test.junit4) testImplementation(libs.mockito.kotlin) testImplementation(libs.datafaker) // fastlane screengrab androidTestImplementation(libs.fastlane.screengrab) } ================================================ FILE: app/gradle.properties ================================================ # set to 'true' to apply blur effect to images (note: build target must be 'debug') screenshotMode=false ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html #-printusage r8-report/usage.txt # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile -dontobfuscate # General -keepattributes SourceFile,LineNumberTable,Signature,*Annotation*,EnclosingMethod,Exceptions,InnerClasses # Kotlin reflection -keep class kotlin.Metadata { *; } # Slf4j -dontwarn org.slf4j.impl.StaticLoggerBinder -dontwarn org.slf4j.impl.StaticMDCBinder # Jackson -keepnames class com.fasterxml.jackson.** { *; } -keepclassmembers class * { @com.fasterxml.jackson.annotation.* *; } -dontwarn com.fasterxml.jackson.databind.** # jsonapi-converter -keepclassmembers class * { @com.github.jasminb.jsonapi.annotations.* *; } -keep class * implements com.github.jasminb.jsonapi.ResourceIdHandler # MPAndroidChart -keep class com.github.mikephil.charting.** { *; } ############################################ # Kitsune specific rules ############################################ # keep all classes -keep class io.github.drumber.kitsune.** { *; } # keep search filters -keep class io.github.drumber.kitsune.domain.algolia.FilterCollectionEntry** { *; } -keep class com.algolia.instantsearch.filter.state.FilterGroupID** { *; } -keep class com.algolia.instantsearch.filter.state.Filters** { *; } -keep class com.algolia.search.model.filter.Filter** { *; } -keep class com.algolia.search.model.Attribute** { *; } ================================================ FILE: app/schemas/io.github.drumber.kitsune.data.source.local.LocalDatabase/1.json ================================================ { "formatVersion": 1, "database": { "version": 1, "identityHash": "9ffeea6c7e014697eeeb8de0dbec5a99", "entities": [ { "tableName": "library_entries", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `updatedAt` TEXT, `startedAt` TEXT, `finishedAt` TEXT, `progressedAt` TEXT, `status` INTEGER, `progress` INTEGER, `reconsuming` INTEGER, `reconsumeCount` INTEGER, `volumesOwned` INTEGER, `ratingTwenty` INTEGER, `notes` TEXT, `privateEntry` INTEGER, `reactionSkipped` TEXT, `anime_id` TEXT, `anime_slug` TEXT, `anime_description` TEXT, `anime_titles` TEXT, `anime_canonicalTitle` TEXT, `anime_abbreviatedTitles` TEXT, `anime_averageRating` TEXT, `anime_userCount` INTEGER, `anime_favoritesCount` INTEGER, `anime_popularityRank` INTEGER, `anime_ratingRank` INTEGER, `anime_startDate` TEXT, `anime_endDate` TEXT, `anime_nextRelease` TEXT, `anime_tba` TEXT, `anime_status` TEXT, `anime_ageRating` TEXT, `anime_ageRatingGuide` TEXT, `anime_nsfw` INTEGER, `anime_totalLength` INTEGER, `anime_episodeCount` INTEGER, `anime_episodeLength` INTEGER, `anime_youtubeVideoId` TEXT, `anime_subtype` TEXT, `anime_rating_r2` TEXT, `anime_rating_r3` TEXT, `anime_rating_r4` TEXT, `anime_rating_r5` TEXT, `anime_rating_r6` TEXT, `anime_rating_r7` TEXT, `anime_rating_r8` TEXT, `anime_rating_r9` TEXT, `anime_rating_r10` TEXT, `anime_rating_r11` TEXT, `anime_rating_r12` TEXT, `anime_rating_r13` TEXT, `anime_rating_r14` TEXT, `anime_rating_r15` TEXT, `anime_rating_r16` TEXT, `anime_rating_r17` TEXT, `anime_rating_r18` TEXT, `anime_rating_r19` TEXT, `anime_rating_r20` TEXT, `anime_poster_tiny` TEXT, `anime_poster_small` TEXT, `anime_poster_medium` TEXT, `anime_poster_large` TEXT, `anime_poster_original` TEXT, `anime_poster_meta_tiny_width` INTEGER, `anime_poster_meta_tiny_height` INTEGER, `anime_poster_meta_small_width` INTEGER, `anime_poster_meta_small_height` INTEGER, `anime_poster_meta_medium_width` INTEGER, `anime_poster_meta_medium_height` INTEGER, `anime_poster_meta_large_width` INTEGER, `anime_poster_meta_large_height` INTEGER, `anime_cover_tiny` TEXT, `anime_cover_small` TEXT, `anime_cover_medium` TEXT, `anime_cover_large` TEXT, `anime_cover_original` TEXT, `anime_cover_meta_tiny_width` INTEGER, `anime_cover_meta_tiny_height` INTEGER, `anime_cover_meta_small_width` INTEGER, `anime_cover_meta_small_height` INTEGER, `anime_cover_meta_medium_width` INTEGER, `anime_cover_meta_medium_height` INTEGER, `anime_cover_meta_large_width` INTEGER, `anime_cover_meta_large_height` INTEGER, `manga_id` TEXT, `manga_slug` TEXT, `manga_description` TEXT, `manga_titles` TEXT, `manga_canonicalTitle` TEXT, `manga_abbreviatedTitles` TEXT, `manga_averageRating` TEXT, `manga_userCount` INTEGER, `manga_favoritesCount` INTEGER, `manga_popularityRank` INTEGER, `manga_ratingRank` INTEGER, `manga_startDate` TEXT, `manga_endDate` TEXT, `manga_nextRelease` TEXT, `manga_tba` TEXT, `manga_status` TEXT, `manga_ageRating` TEXT, `manga_ageRatingGuide` TEXT, `manga_nsfw` INTEGER, `manga_totalLength` INTEGER, `manga_chapterCount` INTEGER, `manga_volumeCount` INTEGER, `manga_subtype` TEXT, `manga_serialization` TEXT, `manga_rating_r2` TEXT, `manga_rating_r3` TEXT, `manga_rating_r4` TEXT, `manga_rating_r5` TEXT, `manga_rating_r6` TEXT, `manga_rating_r7` TEXT, `manga_rating_r8` TEXT, `manga_rating_r9` TEXT, `manga_rating_r10` TEXT, `manga_rating_r11` TEXT, `manga_rating_r12` TEXT, `manga_rating_r13` TEXT, `manga_rating_r14` TEXT, `manga_rating_r15` TEXT, `manga_rating_r16` TEXT, `manga_rating_r17` TEXT, `manga_rating_r18` TEXT, `manga_rating_r19` TEXT, `manga_rating_r20` TEXT, `manga_poster_tiny` TEXT, `manga_poster_small` TEXT, `manga_poster_medium` TEXT, `manga_poster_large` TEXT, `manga_poster_original` TEXT, `manga_poster_meta_tiny_width` INTEGER, `manga_poster_meta_tiny_height` INTEGER, `manga_poster_meta_small_width` INTEGER, `manga_poster_meta_small_height` INTEGER, `manga_poster_meta_medium_width` INTEGER, `manga_poster_meta_medium_height` INTEGER, `manga_poster_meta_large_width` INTEGER, `manga_poster_meta_large_height` INTEGER, `manga_cover_tiny` TEXT, `manga_cover_small` TEXT, `manga_cover_medium` TEXT, `manga_cover_large` TEXT, `manga_cover_original` TEXT, `manga_cover_meta_tiny_width` INTEGER, `manga_cover_meta_tiny_height` INTEGER, `manga_cover_meta_small_width` INTEGER, `manga_cover_meta_small_height` INTEGER, `manga_cover_meta_medium_width` INTEGER, `manga_cover_meta_medium_height` INTEGER, `manga_cover_meta_large_width` INTEGER, `manga_cover_meta_large_height` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updatedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "startedAt", "columnName": "startedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "finishedAt", "columnName": "finishedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "progressedAt", "columnName": "progressedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "progress", "columnName": "progress", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsuming", "columnName": "reconsuming", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsumeCount", "columnName": "reconsumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "volumesOwned", "columnName": "volumesOwned", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "ratingTwenty", "columnName": "ratingTwenty", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "privateEntry", "columnName": "privateEntry", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reactionSkipped", "columnName": "reactionSkipped", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.id", "columnName": "anime_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.slug", "columnName": "anime_slug", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.description", "columnName": "anime_description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.titles", "columnName": "anime_titles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.canonicalTitle", "columnName": "anime_canonicalTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.abbreviatedTitles", "columnName": "anime_abbreviatedTitles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.averageRating", "columnName": "anime_averageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.userCount", "columnName": "anime_userCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.favoritesCount", "columnName": "anime_favoritesCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.popularityRank", "columnName": "anime_popularityRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.ratingRank", "columnName": "anime_ratingRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.startDate", "columnName": "anime_startDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.endDate", "columnName": "anime_endDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.nextRelease", "columnName": "anime_nextRelease", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.tba", "columnName": "anime_tba", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.status", "columnName": "anime_status", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ageRating", "columnName": "anime_ageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ageRatingGuide", "columnName": "anime_ageRatingGuide", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.nsfw", "columnName": "anime_nsfw", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.totalLength", "columnName": "anime_totalLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.episodeCount", "columnName": "anime_episodeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.episodeLength", "columnName": "anime_episodeLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.youtubeVideoId", "columnName": "anime_youtubeVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.subtype", "columnName": "anime_subtype", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r2", "columnName": "anime_rating_r2", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r3", "columnName": "anime_rating_r3", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r4", "columnName": "anime_rating_r4", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r5", "columnName": "anime_rating_r5", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r6", "columnName": "anime_rating_r6", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r7", "columnName": "anime_rating_r7", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r8", "columnName": "anime_rating_r8", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r9", "columnName": "anime_rating_r9", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r10", "columnName": "anime_rating_r10", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r11", "columnName": "anime_rating_r11", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r12", "columnName": "anime_rating_r12", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r13", "columnName": "anime_rating_r13", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r14", "columnName": "anime_rating_r14", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r15", "columnName": "anime_rating_r15", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r16", "columnName": "anime_rating_r16", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r17", "columnName": "anime_rating_r17", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r18", "columnName": "anime_rating_r18", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r19", "columnName": "anime_rating_r19", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r20", "columnName": "anime_rating_r20", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.tiny", "columnName": "anime_poster_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.small", "columnName": "anime_poster_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.medium", "columnName": "anime_poster_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.large", "columnName": "anime_poster_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.original", "columnName": "anime_poster_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.tiny.width", "columnName": "anime_poster_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.tiny.height", "columnName": "anime_poster_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.small.width", "columnName": "anime_poster_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.small.height", "columnName": "anime_poster_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.medium.width", "columnName": "anime_poster_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.medium.height", "columnName": "anime_poster_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.large.width", "columnName": "anime_poster_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.large.height", "columnName": "anime_poster_meta_large_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.tiny", "columnName": "anime_cover_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.small", "columnName": "anime_cover_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.medium", "columnName": "anime_cover_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.large", "columnName": "anime_cover_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.original", "columnName": "anime_cover_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.tiny.width", "columnName": "anime_cover_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.tiny.height", "columnName": "anime_cover_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.small.width", "columnName": "anime_cover_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.small.height", "columnName": "anime_cover_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.medium.width", "columnName": "anime_cover_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.medium.height", "columnName": "anime_cover_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.large.width", "columnName": "anime_cover_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.large.height", "columnName": "anime_cover_meta_large_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.id", "columnName": "manga_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.slug", "columnName": "manga_slug", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.description", "columnName": "manga_description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.titles", "columnName": "manga_titles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.canonicalTitle", "columnName": "manga_canonicalTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.abbreviatedTitles", "columnName": "manga_abbreviatedTitles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.averageRating", "columnName": "manga_averageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.userCount", "columnName": "manga_userCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.favoritesCount", "columnName": "manga_favoritesCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.popularityRank", "columnName": "manga_popularityRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.ratingRank", "columnName": "manga_ratingRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.startDate", "columnName": "manga_startDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.endDate", "columnName": "manga_endDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.nextRelease", "columnName": "manga_nextRelease", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.tba", "columnName": "manga_tba", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.status", "columnName": "manga_status", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ageRating", "columnName": "manga_ageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ageRatingGuide", "columnName": "manga_ageRatingGuide", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.nsfw", "columnName": "manga_nsfw", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.totalLength", "columnName": "manga_totalLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.chapterCount", "columnName": "manga_chapterCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.volumeCount", "columnName": "manga_volumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.subtype", "columnName": "manga_subtype", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.serialization", "columnName": "manga_serialization", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r2", "columnName": "manga_rating_r2", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r3", "columnName": "manga_rating_r3", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r4", "columnName": "manga_rating_r4", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r5", "columnName": "manga_rating_r5", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r6", "columnName": "manga_rating_r6", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r7", "columnName": "manga_rating_r7", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r8", "columnName": "manga_rating_r8", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r9", "columnName": "manga_rating_r9", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r10", "columnName": "manga_rating_r10", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r11", "columnName": "manga_rating_r11", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r12", "columnName": "manga_rating_r12", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r13", "columnName": "manga_rating_r13", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r14", "columnName": "manga_rating_r14", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r15", "columnName": "manga_rating_r15", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r16", "columnName": "manga_rating_r16", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r17", "columnName": "manga_rating_r17", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r18", "columnName": "manga_rating_r18", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r19", "columnName": "manga_rating_r19", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r20", "columnName": "manga_rating_r20", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.tiny", "columnName": "manga_poster_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.small", "columnName": "manga_poster_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.medium", "columnName": "manga_poster_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.large", "columnName": "manga_poster_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.original", "columnName": "manga_poster_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.tiny.width", "columnName": "manga_poster_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.tiny.height", "columnName": "manga_poster_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.small.width", "columnName": "manga_poster_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.small.height", "columnName": "manga_poster_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.medium.width", "columnName": "manga_poster_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.medium.height", "columnName": "manga_poster_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.large.width", "columnName": "manga_poster_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.large.height", "columnName": "manga_poster_meta_large_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.tiny", "columnName": "manga_cover_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.small", "columnName": "manga_cover_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.medium", "columnName": "manga_cover_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.large", "columnName": "manga_cover_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.original", "columnName": "manga_cover_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.tiny.width", "columnName": "manga_cover_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.tiny.height", "columnName": "manga_cover_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.small.width", "columnName": "manga_cover_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.small.height", "columnName": "manga_cover_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.medium.width", "columnName": "manga_cover_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.medium.height", "columnName": "manga_cover_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.large.width", "columnName": "manga_cover_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.large.height", "columnName": "manga_cover_meta_large_height", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "library_entries_modifications", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `state` TEXT NOT NULL, `startedAt` TEXT, `finishedAt` TEXT, `status` INTEGER, `progress` INTEGER, `reconsumeCount` INTEGER, `volumesOwned` INTEGER, `ratingTwenty` INTEGER, `notes` TEXT, `privateEntry` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "state", "columnName": "state", "affinity": "TEXT", "notNull": true }, { "fieldPath": "startedAt", "columnName": "startedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "finishedAt", "columnName": "finishedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "progress", "columnName": "progress", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsumeCount", "columnName": "reconsumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "volumesOwned", "columnName": "volumesOwned", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "ratingTwenty", "columnName": "ratingTwenty", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "privateEntry", "columnName": "privateEntry", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "remote_keys", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resourceId` TEXT NOT NULL COLLATE NOCASE, `remoteKeyType` TEXT NOT NULL, `prevPageKey` INTEGER, `nextPageKey` INTEGER, PRIMARY KEY(`resourceId`))", "fields": [ { "fieldPath": "resourceId", "columnName": "resourceId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "remoteKeyType", "columnName": "remoteKeyType", "affinity": "TEXT", "notNull": true }, { "fieldPath": "prevPageKey", "columnName": "prevPageKey", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "nextPageKey", "columnName": "nextPageKey", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "resourceId" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ffeea6c7e014697eeeb8de0dbec5a99')" ] } } ================================================ FILE: app/schemas/io.github.drumber.kitsune.data.source.local.LocalDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "21e2d33c7212c6e12b735dc22bf563f5", "entities": [ { "tableName": "library_entries", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `updatedAt` TEXT, `startedAt` TEXT, `finishedAt` TEXT, `progressedAt` TEXT, `status` INTEGER, `progress` INTEGER, `reconsuming` INTEGER, `reconsumeCount` INTEGER, `volumesOwned` INTEGER, `ratingTwenty` INTEGER, `notes` TEXT, `privateEntry` INTEGER, `reactionSkipped` TEXT, `anime_id` TEXT, `anime_slug` TEXT, `anime_description` TEXT, `anime_titles` TEXT, `anime_canonicalTitle` TEXT, `anime_abbreviatedTitles` TEXT, `anime_averageRating` TEXT, `anime_userCount` INTEGER, `anime_favoritesCount` INTEGER, `anime_popularityRank` INTEGER, `anime_ratingRank` INTEGER, `anime_startDate` TEXT, `anime_endDate` TEXT, `anime_nextRelease` TEXT, `anime_tba` TEXT, `anime_status` TEXT, `anime_ageRating` TEXT, `anime_ageRatingGuide` TEXT, `anime_nsfw` INTEGER, `anime_totalLength` INTEGER, `anime_episodeCount` INTEGER, `anime_episodeLength` INTEGER, `anime_youtubeVideoId` TEXT, `anime_subtype` TEXT, `anime_rating_r2` TEXT, `anime_rating_r3` TEXT, `anime_rating_r4` TEXT, `anime_rating_r5` TEXT, `anime_rating_r6` TEXT, `anime_rating_r7` TEXT, `anime_rating_r8` TEXT, `anime_rating_r9` TEXT, `anime_rating_r10` TEXT, `anime_rating_r11` TEXT, `anime_rating_r12` TEXT, `anime_rating_r13` TEXT, `anime_rating_r14` TEXT, `anime_rating_r15` TEXT, `anime_rating_r16` TEXT, `anime_rating_r17` TEXT, `anime_rating_r18` TEXT, `anime_rating_r19` TEXT, `anime_rating_r20` TEXT, `anime_poster_tiny` TEXT, `anime_poster_small` TEXT, `anime_poster_medium` TEXT, `anime_poster_large` TEXT, `anime_poster_original` TEXT, `anime_poster_meta_tiny_width` INTEGER, `anime_poster_meta_tiny_height` INTEGER, `anime_poster_meta_small_width` INTEGER, `anime_poster_meta_small_height` INTEGER, `anime_poster_meta_medium_width` INTEGER, `anime_poster_meta_medium_height` INTEGER, `anime_poster_meta_large_width` INTEGER, `anime_poster_meta_large_height` INTEGER, `anime_cover_tiny` TEXT, `anime_cover_small` TEXT, `anime_cover_medium` TEXT, `anime_cover_large` TEXT, `anime_cover_original` TEXT, `anime_cover_meta_tiny_width` INTEGER, `anime_cover_meta_tiny_height` INTEGER, `anime_cover_meta_small_width` INTEGER, `anime_cover_meta_small_height` INTEGER, `anime_cover_meta_medium_width` INTEGER, `anime_cover_meta_medium_height` INTEGER, `anime_cover_meta_large_width` INTEGER, `anime_cover_meta_large_height` INTEGER, `manga_id` TEXT, `manga_slug` TEXT, `manga_description` TEXT, `manga_titles` TEXT, `manga_canonicalTitle` TEXT, `manga_abbreviatedTitles` TEXT, `manga_averageRating` TEXT, `manga_userCount` INTEGER, `manga_favoritesCount` INTEGER, `manga_popularityRank` INTEGER, `manga_ratingRank` INTEGER, `manga_startDate` TEXT, `manga_endDate` TEXT, `manga_nextRelease` TEXT, `manga_tba` TEXT, `manga_status` TEXT, `manga_ageRating` TEXT, `manga_ageRatingGuide` TEXT, `manga_nsfw` INTEGER, `manga_totalLength` INTEGER, `manga_chapterCount` INTEGER, `manga_volumeCount` INTEGER, `manga_subtype` TEXT, `manga_serialization` TEXT, `manga_rating_r2` TEXT, `manga_rating_r3` TEXT, `manga_rating_r4` TEXT, `manga_rating_r5` TEXT, `manga_rating_r6` TEXT, `manga_rating_r7` TEXT, `manga_rating_r8` TEXT, `manga_rating_r9` TEXT, `manga_rating_r10` TEXT, `manga_rating_r11` TEXT, `manga_rating_r12` TEXT, `manga_rating_r13` TEXT, `manga_rating_r14` TEXT, `manga_rating_r15` TEXT, `manga_rating_r16` TEXT, `manga_rating_r17` TEXT, `manga_rating_r18` TEXT, `manga_rating_r19` TEXT, `manga_rating_r20` TEXT, `manga_poster_tiny` TEXT, `manga_poster_small` TEXT, `manga_poster_medium` TEXT, `manga_poster_large` TEXT, `manga_poster_original` TEXT, `manga_poster_meta_tiny_width` INTEGER, `manga_poster_meta_tiny_height` INTEGER, `manga_poster_meta_small_width` INTEGER, `manga_poster_meta_small_height` INTEGER, `manga_poster_meta_medium_width` INTEGER, `manga_poster_meta_medium_height` INTEGER, `manga_poster_meta_large_width` INTEGER, `manga_poster_meta_large_height` INTEGER, `manga_cover_tiny` TEXT, `manga_cover_small` TEXT, `manga_cover_medium` TEXT, `manga_cover_large` TEXT, `manga_cover_original` TEXT, `manga_cover_meta_tiny_width` INTEGER, `manga_cover_meta_tiny_height` INTEGER, `manga_cover_meta_small_width` INTEGER, `manga_cover_meta_small_height` INTEGER, `manga_cover_meta_medium_width` INTEGER, `manga_cover_meta_medium_height` INTEGER, `manga_cover_meta_large_width` INTEGER, `manga_cover_meta_large_height` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updatedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "startedAt", "columnName": "startedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "finishedAt", "columnName": "finishedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "progressedAt", "columnName": "progressedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "progress", "columnName": "progress", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsuming", "columnName": "reconsuming", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsumeCount", "columnName": "reconsumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "volumesOwned", "columnName": "volumesOwned", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "ratingTwenty", "columnName": "ratingTwenty", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "privateEntry", "columnName": "privateEntry", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reactionSkipped", "columnName": "reactionSkipped", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.id", "columnName": "anime_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.slug", "columnName": "anime_slug", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.description", "columnName": "anime_description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.titles", "columnName": "anime_titles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.canonicalTitle", "columnName": "anime_canonicalTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.abbreviatedTitles", "columnName": "anime_abbreviatedTitles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.averageRating", "columnName": "anime_averageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.userCount", "columnName": "anime_userCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.favoritesCount", "columnName": "anime_favoritesCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.popularityRank", "columnName": "anime_popularityRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.ratingRank", "columnName": "anime_ratingRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.startDate", "columnName": "anime_startDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.endDate", "columnName": "anime_endDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.nextRelease", "columnName": "anime_nextRelease", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.tba", "columnName": "anime_tba", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.status", "columnName": "anime_status", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ageRating", "columnName": "anime_ageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ageRatingGuide", "columnName": "anime_ageRatingGuide", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.nsfw", "columnName": "anime_nsfw", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.totalLength", "columnName": "anime_totalLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.episodeCount", "columnName": "anime_episodeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.episodeLength", "columnName": "anime_episodeLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.youtubeVideoId", "columnName": "anime_youtubeVideoId", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.subtype", "columnName": "anime_subtype", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r2", "columnName": "anime_rating_r2", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r3", "columnName": "anime_rating_r3", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r4", "columnName": "anime_rating_r4", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r5", "columnName": "anime_rating_r5", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r6", "columnName": "anime_rating_r6", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r7", "columnName": "anime_rating_r7", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r8", "columnName": "anime_rating_r8", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r9", "columnName": "anime_rating_r9", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r10", "columnName": "anime_rating_r10", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r11", "columnName": "anime_rating_r11", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r12", "columnName": "anime_rating_r12", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r13", "columnName": "anime_rating_r13", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r14", "columnName": "anime_rating_r14", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r15", "columnName": "anime_rating_r15", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r16", "columnName": "anime_rating_r16", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r17", "columnName": "anime_rating_r17", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r18", "columnName": "anime_rating_r18", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r19", "columnName": "anime_rating_r19", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.ratingFrequencies.r20", "columnName": "anime_rating_r20", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.tiny", "columnName": "anime_poster_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.small", "columnName": "anime_poster_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.medium", "columnName": "anime_poster_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.large", "columnName": "anime_poster_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.original", "columnName": "anime_poster_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.tiny.width", "columnName": "anime_poster_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.tiny.height", "columnName": "anime_poster_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.small.width", "columnName": "anime_poster_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.small.height", "columnName": "anime_poster_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.medium.width", "columnName": "anime_poster_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.medium.height", "columnName": "anime_poster_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.large.width", "columnName": "anime_poster_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.posterImage.meta.dimensions.large.height", "columnName": "anime_poster_meta_large_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.tiny", "columnName": "anime_cover_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.small", "columnName": "anime_cover_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.medium", "columnName": "anime_cover_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.large", "columnName": "anime_cover_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.original", "columnName": "anime_cover_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.tiny.width", "columnName": "anime_cover_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.tiny.height", "columnName": "anime_cover_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.small.width", "columnName": "anime_cover_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.small.height", "columnName": "anime_cover_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.medium.width", "columnName": "anime_cover_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.medium.height", "columnName": "anime_cover_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.large.width", "columnName": "anime_cover_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "anime.coverImage.meta.dimensions.large.height", "columnName": "anime_cover_meta_large_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.id", "columnName": "manga_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.slug", "columnName": "manga_slug", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.description", "columnName": "manga_description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.titles", "columnName": "manga_titles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.canonicalTitle", "columnName": "manga_canonicalTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.abbreviatedTitles", "columnName": "manga_abbreviatedTitles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.averageRating", "columnName": "manga_averageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.userCount", "columnName": "manga_userCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.favoritesCount", "columnName": "manga_favoritesCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.popularityRank", "columnName": "manga_popularityRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.ratingRank", "columnName": "manga_ratingRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.startDate", "columnName": "manga_startDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.endDate", "columnName": "manga_endDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.nextRelease", "columnName": "manga_nextRelease", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.tba", "columnName": "manga_tba", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.status", "columnName": "manga_status", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ageRating", "columnName": "manga_ageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ageRatingGuide", "columnName": "manga_ageRatingGuide", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.nsfw", "columnName": "manga_nsfw", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.totalLength", "columnName": "manga_totalLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.chapterCount", "columnName": "manga_chapterCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.volumeCount", "columnName": "manga_volumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.subtype", "columnName": "manga_subtype", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.serialization", "columnName": "manga_serialization", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r2", "columnName": "manga_rating_r2", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r3", "columnName": "manga_rating_r3", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r4", "columnName": "manga_rating_r4", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r5", "columnName": "manga_rating_r5", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r6", "columnName": "manga_rating_r6", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r7", "columnName": "manga_rating_r7", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r8", "columnName": "manga_rating_r8", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r9", "columnName": "manga_rating_r9", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r10", "columnName": "manga_rating_r10", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r11", "columnName": "manga_rating_r11", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r12", "columnName": "manga_rating_r12", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r13", "columnName": "manga_rating_r13", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r14", "columnName": "manga_rating_r14", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r15", "columnName": "manga_rating_r15", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r16", "columnName": "manga_rating_r16", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r17", "columnName": "manga_rating_r17", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r18", "columnName": "manga_rating_r18", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r19", "columnName": "manga_rating_r19", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.ratingFrequencies.r20", "columnName": "manga_rating_r20", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.tiny", "columnName": "manga_poster_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.small", "columnName": "manga_poster_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.medium", "columnName": "manga_poster_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.large", "columnName": "manga_poster_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.original", "columnName": "manga_poster_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.tiny.width", "columnName": "manga_poster_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.tiny.height", "columnName": "manga_poster_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.small.width", "columnName": "manga_poster_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.small.height", "columnName": "manga_poster_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.medium.width", "columnName": "manga_poster_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.medium.height", "columnName": "manga_poster_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.large.width", "columnName": "manga_poster_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.posterImage.meta.dimensions.large.height", "columnName": "manga_poster_meta_large_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.tiny", "columnName": "manga_cover_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.small", "columnName": "manga_cover_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.medium", "columnName": "manga_cover_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.large", "columnName": "manga_cover_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.original", "columnName": "manga_cover_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.tiny.width", "columnName": "manga_cover_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.tiny.height", "columnName": "manga_cover_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.small.width", "columnName": "manga_cover_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.small.height", "columnName": "manga_cover_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.medium.width", "columnName": "manga_cover_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.medium.height", "columnName": "manga_cover_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.large.width", "columnName": "manga_cover_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "manga.coverImage.meta.dimensions.large.height", "columnName": "manga_cover_meta_large_height", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "library_entries_modifications", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `createTime` INTEGER NOT NULL, `state` TEXT NOT NULL, `startedAt` TEXT, `finishedAt` TEXT, `status` INTEGER, `progress` INTEGER, `reconsumeCount` INTEGER, `volumesOwned` INTEGER, `ratingTwenty` INTEGER, `notes` TEXT, `privateEntry` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createTime", "columnName": "createTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "state", "columnName": "state", "affinity": "TEXT", "notNull": true }, { "fieldPath": "startedAt", "columnName": "startedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "finishedAt", "columnName": "finishedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "progress", "columnName": "progress", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsumeCount", "columnName": "reconsumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "volumesOwned", "columnName": "volumesOwned", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "ratingTwenty", "columnName": "ratingTwenty", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "privateEntry", "columnName": "privateEntry", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "remote_keys", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resourceId` TEXT NOT NULL COLLATE NOCASE, `remoteKeyType` TEXT NOT NULL, `prevPageKey` INTEGER, `nextPageKey` INTEGER, PRIMARY KEY(`resourceId`))", "fields": [ { "fieldPath": "resourceId", "columnName": "resourceId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "remoteKeyType", "columnName": "remoteKeyType", "affinity": "TEXT", "notNull": true }, { "fieldPath": "prevPageKey", "columnName": "prevPageKey", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "nextPageKey", "columnName": "nextPageKey", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "resourceId" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '21e2d33c7212c6e12b735dc22bf563f5')" ] } } ================================================ FILE: app/schemas/io.github.drumber.kitsune.data.source.local.LocalDatabase/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "7979a798f0004298608e29c0014d940d", "entities": [ { "tableName": "library_entries", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `updatedAt` TEXT, `startedAt` TEXT, `finishedAt` TEXT, `progressedAt` TEXT, `status` INTEGER, `progress` INTEGER, `reconsuming` INTEGER, `reconsumeCount` INTEGER, `volumesOwned` INTEGER, `ratingTwenty` INTEGER, `notes` TEXT, `privateEntry` INTEGER, `reactionSkipped` TEXT, `media_id` TEXT, `media_type` TEXT, `media_description` TEXT, `media_titles` TEXT, `media_canonicalTitle` TEXT, `media_abbreviatedTitles` TEXT, `media_averageRating` TEXT, `media_popularityRank` INTEGER, `media_ratingRank` INTEGER, `media_startDate` TEXT, `media_endDate` TEXT, `media_nextRelease` TEXT, `media_tba` TEXT, `media_status` TEXT, `media_ageRating` TEXT, `media_ageRatingGuide` TEXT, `media_nsfw` INTEGER, `media_animeSubtype` TEXT, `media_totalLength` INTEGER, `media_episodeCount` INTEGER, `media_episodeLength` INTEGER, `media_mangaSubtype` TEXT, `media_chapterCount` INTEGER, `media_volumeCount` INTEGER, `media_serialization` TEXT, `media_rating_r2` TEXT, `media_rating_r3` TEXT, `media_rating_r4` TEXT, `media_rating_r5` TEXT, `media_rating_r6` TEXT, `media_rating_r7` TEXT, `media_rating_r8` TEXT, `media_rating_r9` TEXT, `media_rating_r10` TEXT, `media_rating_r11` TEXT, `media_rating_r12` TEXT, `media_rating_r13` TEXT, `media_rating_r14` TEXT, `media_rating_r15` TEXT, `media_rating_r16` TEXT, `media_rating_r17` TEXT, `media_rating_r18` TEXT, `media_rating_r19` TEXT, `media_rating_r20` TEXT, `media_poster_tiny` TEXT, `media_poster_small` TEXT, `media_poster_medium` TEXT, `media_poster_large` TEXT, `media_poster_original` TEXT, `media_poster_meta_tiny_width` INTEGER, `media_poster_meta_tiny_height` INTEGER, `media_poster_meta_small_width` INTEGER, `media_poster_meta_small_height` INTEGER, `media_poster_meta_medium_width` INTEGER, `media_poster_meta_medium_height` INTEGER, `media_poster_meta_large_width` INTEGER, `media_poster_meta_large_height` INTEGER, `media_cover_tiny` TEXT, `media_cover_small` TEXT, `media_cover_medium` TEXT, `media_cover_large` TEXT, `media_cover_original` TEXT, `media_cover_meta_tiny_width` INTEGER, `media_cover_meta_tiny_height` INTEGER, `media_cover_meta_small_width` INTEGER, `media_cover_meta_small_height` INTEGER, `media_cover_meta_medium_width` INTEGER, `media_cover_meta_medium_height` INTEGER, `media_cover_meta_large_width` INTEGER, `media_cover_meta_large_height` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "updatedAt", "columnName": "updatedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "startedAt", "columnName": "startedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "finishedAt", "columnName": "finishedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "progressedAt", "columnName": "progressedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "progress", "columnName": "progress", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsuming", "columnName": "reconsuming", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsumeCount", "columnName": "reconsumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "volumesOwned", "columnName": "volumesOwned", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "ratingTwenty", "columnName": "ratingTwenty", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "privateEntry", "columnName": "privateEntry", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reactionSkipped", "columnName": "reactionSkipped", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.id", "columnName": "media_id", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.type", "columnName": "media_type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.description", "columnName": "media_description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.titles", "columnName": "media_titles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.canonicalTitle", "columnName": "media_canonicalTitle", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.abbreviatedTitles", "columnName": "media_abbreviatedTitles", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.averageRating", "columnName": "media_averageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.popularityRank", "columnName": "media_popularityRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.ratingRank", "columnName": "media_ratingRank", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.startDate", "columnName": "media_startDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.endDate", "columnName": "media_endDate", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.nextRelease", "columnName": "media_nextRelease", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.tba", "columnName": "media_tba", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.status", "columnName": "media_status", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ageRating", "columnName": "media_ageRating", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ageRatingGuide", "columnName": "media_ageRatingGuide", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.nsfw", "columnName": "media_nsfw", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.animeSubtype", "columnName": "media_animeSubtype", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.totalLength", "columnName": "media_totalLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.episodeCount", "columnName": "media_episodeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.episodeLength", "columnName": "media_episodeLength", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.mangaSubtype", "columnName": "media_mangaSubtype", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.chapterCount", "columnName": "media_chapterCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.volumeCount", "columnName": "media_volumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.serialization", "columnName": "media_serialization", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r2", "columnName": "media_rating_r2", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r3", "columnName": "media_rating_r3", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r4", "columnName": "media_rating_r4", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r5", "columnName": "media_rating_r5", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r6", "columnName": "media_rating_r6", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r7", "columnName": "media_rating_r7", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r8", "columnName": "media_rating_r8", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r9", "columnName": "media_rating_r9", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r10", "columnName": "media_rating_r10", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r11", "columnName": "media_rating_r11", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r12", "columnName": "media_rating_r12", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r13", "columnName": "media_rating_r13", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r14", "columnName": "media_rating_r14", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r15", "columnName": "media_rating_r15", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r16", "columnName": "media_rating_r16", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r17", "columnName": "media_rating_r17", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r18", "columnName": "media_rating_r18", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r19", "columnName": "media_rating_r19", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.ratingFrequencies.r20", "columnName": "media_rating_r20", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.posterImage.tiny", "columnName": "media_poster_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.posterImage.small", "columnName": "media_poster_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.posterImage.medium", "columnName": "media_poster_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.posterImage.large", "columnName": "media_poster_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.posterImage.original", "columnName": "media_poster_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.tiny.width", "columnName": "media_poster_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.tiny.height", "columnName": "media_poster_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.small.width", "columnName": "media_poster_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.small.height", "columnName": "media_poster_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.medium.width", "columnName": "media_poster_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.medium.height", "columnName": "media_poster_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.large.width", "columnName": "media_poster_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.posterImage.meta.dimensions.large.height", "columnName": "media_poster_meta_large_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.tiny", "columnName": "media_cover_tiny", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.coverImage.small", "columnName": "media_cover_small", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.coverImage.medium", "columnName": "media_cover_medium", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.coverImage.large", "columnName": "media_cover_large", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.coverImage.original", "columnName": "media_cover_original", "affinity": "TEXT", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.tiny.width", "columnName": "media_cover_meta_tiny_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.tiny.height", "columnName": "media_cover_meta_tiny_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.small.width", "columnName": "media_cover_meta_small_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.small.height", "columnName": "media_cover_meta_small_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.medium.width", "columnName": "media_cover_meta_medium_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.medium.height", "columnName": "media_cover_meta_medium_height", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.large.width", "columnName": "media_cover_meta_large_width", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "media.coverImage.meta.dimensions.large.height", "columnName": "media_cover_meta_large_height", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "library_entries_modifications", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `createTime` INTEGER NOT NULL, `state` TEXT NOT NULL, `startedAt` TEXT, `finishedAt` TEXT, `status` INTEGER, `progress` INTEGER, `reconsumeCount` INTEGER, `volumesOwned` INTEGER, `ratingTwenty` INTEGER, `notes` TEXT, `privateEntry` INTEGER, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "TEXT", "notNull": true }, { "fieldPath": "createTime", "columnName": "createTime", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "state", "columnName": "state", "affinity": "TEXT", "notNull": true }, { "fieldPath": "startedAt", "columnName": "startedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "finishedAt", "columnName": "finishedAt", "affinity": "TEXT", "notNull": false }, { "fieldPath": "status", "columnName": "status", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "progress", "columnName": "progress", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "reconsumeCount", "columnName": "reconsumeCount", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "volumesOwned", "columnName": "volumesOwned", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "ratingTwenty", "columnName": "ratingTwenty", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "notes", "columnName": "notes", "affinity": "TEXT", "notNull": false }, { "fieldPath": "privateEntry", "columnName": "privateEntry", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "id" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "remote_keys", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`resourceId` TEXT NOT NULL COLLATE NOCASE, `remoteKeyType` TEXT NOT NULL, `prevPageKey` INTEGER, `nextPageKey` INTEGER, PRIMARY KEY(`resourceId`))", "fields": [ { "fieldPath": "resourceId", "columnName": "resourceId", "affinity": "TEXT", "notNull": true }, { "fieldPath": "remoteKeyType", "columnName": "remoteKeyType", "affinity": "TEXT", "notNull": true }, { "fieldPath": "prevPageKey", "columnName": "prevPageKey", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "nextPageKey", "columnName": "nextPageKey", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "resourceId" ] }, "indices": [], "foreignKeys": [] } ], "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7979a798f0004298608e29c0014d940d')" ] } } ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/fastlane/CaptureScreenshots.kt ================================================ package io.github.drumber.kitsune.fastlane import android.net.Uri import androidx.appcompat.app.AppCompatDelegate import androidx.navigation.findNavController import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.action.ViewActions.swipeUp import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.internal.runner.junit4.statement.UiThreadStatement import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import io.github.drumber.kitsune.BuildConfig import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.AppTheme import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.main.MainActivity import io.github.drumber.kitsune.utils.OkHttpIdlingResource import io.github.drumber.kitsune.utils.filter.RequiresScreenshotMode import io.github.drumber.kitsune.utils.waitForView import okhttp3.OkHttpClient import org.junit.AfterClass import org.junit.Assume.assumeTrue import org.junit.BeforeClass import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.qualifier.named import tools.fastlane.screengrab.Screengrab import tools.fastlane.screengrab.cleanstatusbar.CleanStatusBar import kotlin.time.Duration.Companion.seconds @RunWith(AndroidJUnit4::class) class CaptureScreenshots : KoinComponent { @get:Rule var activityRule = ActivityScenarioRule(MainActivity::class.java) @get:Rule var runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( android.Manifest.permission.POST_NOTIFICATIONS, android.Manifest.permission.DUMP ) companion object { @BeforeClass @JvmStatic fun beforeAll() { assumeTrue(BuildConfig.SCREENSHOT_MODE_ENABLED) } @AfterClass @JvmStatic fun afterAll() { CleanStatusBar.disable() } fun enterDemoMode() { CleanStatusBar() .setNetworkFullyConnected(true) .setShowNotifications(false) .setMobileNetworkLevel(4) .setClock("1200") .enable() } } @RequiresScreenshotMode @Test fun testTakeScreenshot() { enterDemoMode() val idlingResource = mutableListOf() activityRule.scenario.onActivity { val client: OkHttpClient = get() val imageClient: OkHttpClient = get(named("images")) idlingResource.add(OkHttpIdlingResource(client)) idlingResource.add(OkHttpIdlingResource(imageClient)) } IdlingRegistry.getInstance().register(*idlingResource.toTypedArray()) // Light Mode KitsunePref.darkMode = AppCompatDelegate.MODE_NIGHT_NO.toString() takeHomeScreenshots("light") takeSearchScreenshots("light") takeDetailsScreenshot("light") // Dark Mode UiThreadStatement.runOnUiThread { KitsunePref.darkMode = AppCompatDelegate.MODE_NIGHT_YES.toString() AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) } takeHomeScreenshots("dark") takeSearchScreenshots("dark") takeDetailsScreenshot("dark") // Purple theme with Dark Mode UiThreadStatement.runOnUiThread { KitsunePref.appTheme = AppTheme.PURPLE } takeHomeScreenshots("dark_purple") // Purple theme with Light Mode UiThreadStatement.runOnUiThread { KitsunePref.darkMode = AppCompatDelegate.MODE_NIGHT_NO.toString() AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) } takeHomeScreenshots("light_purple") IdlingRegistry.getInstance().unregister(*idlingResource.toTypedArray()) idlingResource.clear() } private fun takeHomeScreenshots(prefix: String) { onView(withId(R.id.main_fragment)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() Thread.sleep(1000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() Screengrab.screenshot("${prefix}_home_screen") } private fun takeSearchScreenshots(prefix: String) { onView(withId(R.id.search_fragment)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() Thread.sleep(3000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() Screengrab.screenshot("${prefix}_search_screen") } private fun takeDetailsScreenshot(prefix: String) { activityRule.scenario.onActivity { activity -> val navController = activity.findNavController(R.id.nav_host_fragment) navController.navigate(Uri.parse("${Kitsu.BASE_URL}/anime/12")) } onView(isRoot()).perform(waitForView(R.id.tv_description, 30.seconds)) InstrumentationRegistry.getInstrumentation().waitForIdleSync() Thread.sleep(3000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() Screengrab.screenshot("${prefix}_details_screen") Thread.sleep(3000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() onView(withId(R.id.layout_ratings)).perform(scrollTo()) onView(withId(R.id.nsv_content)).perform(swipeUp()) Thread.sleep(100) // wait for scroll InstrumentationRegistry.getInstrumentation().waitForIdleSync() Screengrab.screenshot("${prefix}_details_ratings_screen") pressBack() } } ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/navigation/NavigationTest.kt ================================================ package io.github.drumber.kitsune.navigation import android.net.Uri import androidx.navigation.findNavController import androidx.test.espresso.Espresso.onView import androidx.test.espresso.IdlingRegistry import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.pressBack import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.action.ViewActions.typeText import androidx.test.espresso.assertion.ViewAssertions.matches import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.matcher.ViewMatchers.isEnabled import androidx.test.espresso.matcher.ViewMatchers.isNotEnabled import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withChild import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.ui.adapter.MediaViewHolder import io.github.drumber.kitsune.ui.adapter.paging.CharacterPagingAdapter.CharacterViewHolder import io.github.drumber.kitsune.ui.adapter.paging.MediaUnitPagingAdapter.MediaUnitViewHolder import io.github.drumber.kitsune.ui.main.MainActivity import io.github.drumber.kitsune.utils.OkHttpIdlingResource import io.github.drumber.kitsune.utils.actionOnChild import io.github.drumber.kitsune.utils.searchText import io.github.drumber.kitsune.utils.waitForView import okhttp3.OkHttpClient import org.hamcrest.core.AllOf.allOf import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.koin.core.component.KoinComponent import org.koin.core.component.get import kotlin.time.Duration.Companion.seconds @RunWith(AndroidJUnit4::class) class NavigationTest : KoinComponent { @get:Rule var activityRule = ActivityScenarioRule(MainActivity::class.java) @get:Rule var runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant( android.Manifest.permission.POST_NOTIFICATIONS ) private var idlingResource: OkHttpIdlingResource? = null @Before fun setup() { activityRule.scenario.onActivity { val client: OkHttpClient = get() idlingResource = OkHttpIdlingResource(client) } IdlingRegistry.getInstance().register(idlingResource!!) } @After fun tearDown() { IdlingRegistry.getInstance().unregister(idlingResource) } @Test fun shouldNavigateToDestinationsFromHome() { onView(withId(R.id.main_fragment)).perform(click()) Thread.sleep(1000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // navigate to trending section onView( allOf( withChild(withText(R.string.section_trending)), withId(R.id.header) ) ).perform(click()) Thread.sleep(1000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // click first media item onView(withId(R.id.rv_media)).perform(actionOnItemAtPosition(0, click())) } @Test fun shouldNavigateToSearchFragment() { onView(withId(R.id.search_fragment)).perform(click()) Thread.sleep(3000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // perform search onView(withId(R.id.search_view)).perform(searchText("toradora")) Thread.sleep(1000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() onView(isRoot()).perform(waitForView(R.id.rv_media, 10.seconds)) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // click first media item onView(withId(R.id.rv_media)).perform(actionOnItemAtPosition(0, click())) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // go back to search fragment onView(isRoot()).perform(pressBack()) // open filters onView(withId(R.id.btn_filter)).perform(click()) Thread.sleep(1000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // open categories onView(withId(R.id.card_categories)).perform(scrollTo(), click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // go back to search fragment onView(isRoot()).perform(pressBack()) onView(isRoot()).perform(pressBack()) } @Test fun shouldNavigateToLibraryFragment() { onView(withId(R.id.library_fragment)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() } @Test fun shouldNavigateToProfileFragmentAndSettings() { onView(withId(R.id.profile_fragment)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // open settings onView(withId(R.id.menu_settings)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // navigate to appearance onView(withText(R.string.nav_appearance)).perform(click()) } @Test fun shouldNavigateToDetailsAndSubPages() { activityRule.scenario.onActivity { activity -> val navController = activity.findNavController(R.id.nav_host_fragment) navController.navigate(Uri.parse("${Kitsu.BASE_URL}/anime/12")) } onView(isRoot()).perform(waitForView(R.id.tv_description, 30.seconds)) Thread.sleep(3000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() Thread.sleep(3000) fun goBack() { onView(isRoot()).perform(pressBack()) Thread.sleep(500) InstrumentationRegistry.getInstrumentation().waitForIdleSync() onView(isRoot()).perform(pressBack()) } fun navigateToEpisodes() { // navigate to episodes onView(withId(R.id.btn_media_units)).perform(scrollTo()) Thread.sleep(100) onView(withId(R.id.btn_media_units)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // click on first episode onView(withId(R.id.rv_media)).perform( actionOnItemAtPosition( 0, click() ) ) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // go back to details fragment goBack() } fun navigateToCharacters() { // navigate to characters onView(withId(R.id.btn_characters)).perform(scrollTo()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() onView(withId(R.id.btn_characters)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // click on first character onView(withId(R.id.rv_media)).perform( actionOnItemAtPosition( 1, actionOnChild(withId(R.id.iv_character), click()) ) ) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // go back to details fragment goBack() } fun navigateToCategory() { // navigate to category onView(withChild(withId(R.id.chip_group_categories))).perform(scrollTo()) onView(withId(R.id.chip_group_categories)).perform(click()) Thread.sleep(1000) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // click first media item onView(withId(R.id.rv_media)).perform(actionOnItemAtPosition(0, click())) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // go back to details fragment goBack() } navigateToEpisodes() navigateToCharacters() navigateToCategory() } @Test fun shouldNavigateToLoginScreen() { // navigate to profile fragment onView(withId(R.id.profile_fragment)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() // navigate to login screen onView(withId(R.id.btn_login)).perform(click()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() onView(withId(R.id.btn_login)).check(matches(isNotEnabled())) onView(withId(R.id.input_username)).perform(typeText("user@example.com")) onView(withId(R.id.btn_login)).check(matches(isNotEnabled())) onView(withId(R.id.input_password)).perform(typeText("password")) onView(withId(R.id.btn_login)).check(matches(isEnabled())) // go back to profile fragment onView(isRoot()).perform(pressBack()) InstrumentationRegistry.getInstrumentation().waitForIdleSync() } } ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/utils/OkHttpIdlingResource.kt ================================================ package io.github.drumber.kitsune.utils import androidx.test.espresso.IdlingResource import okhttp3.Dispatcher import okhttp3.OkHttpClient class OkHttpIdlingResource(okHttpClient: OkHttpClient) : IdlingResource { @Volatile private var callback: IdlingResource.ResourceCallback? = null private val dispatcher: Dispatcher init { dispatcher = okHttpClient.dispatcher okHttpClient.dispatcher.idleCallback = Runnable { callback?.onTransitionToIdle() } } override fun getName(): String { return OkHttpIdlingResource::class.java.name } override fun registerIdleTransitionCallback(callback: IdlingResource.ResourceCallback?) { this.callback = callback } override fun isIdleNow(): Boolean { return dispatcher.runningCallsCount() == 0 } } ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/utils/SearchViewActions.kt ================================================ package io.github.drumber.kitsune.utils import android.view.View import androidx.appcompat.widget.SearchView import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import org.hamcrest.Matcher import org.hamcrest.core.AllOf.allOf fun searchText(query: String) = object : ViewAction { override fun getConstraints(): Matcher { return allOf( isDisplayed(), isAssignableFrom(SearchView::class.java) ) } override fun getDescription() = "Type text into a SearchView" override fun perform(uiController: androidx.test.espresso.UiController, view: View) { val searchView = view as SearchView searchView.setQuery(query, true) } } ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/utils/ViewActions.kt ================================================ package io.github.drumber.kitsune.utils import android.view.View import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.util.TreeIterables import org.hamcrest.Matcher import org.hamcrest.StringDescription import org.hamcrest.core.AllOf.allOf fun actionOnChild(matcher: Matcher, action: ViewAction) = object : ViewAction { override fun getConstraints(): Matcher { return allOf(isDisplayed(), matcher) } override fun getDescription(): String = "Performing action on child" override fun perform(uiController: UiController, view: View) { val results = TreeIterables.breadthFirstViewTraversal(view).filter { matcher.matches(it) } if (results.isEmpty()) { throw RuntimeException("No view found with matcher ${StringDescription.asString(matcher)} in the hierarchy of $view") } else if (results.size > 1) { throw RuntimeException("Multiple views found with matcher ${StringDescription.asString(matcher)} in the hierarchy of $view") } action.perform(uiController, results.first()) } } ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/utils/WaitForView.kt ================================================ package io.github.drumber.kitsune.utils import android.view.View import androidx.annotation.IdRes import androidx.test.espresso.PerformException import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.util.HumanReadables import androidx.test.espresso.util.TreeIterables import org.hamcrest.Matcher import java.util.concurrent.TimeoutException import kotlin.time.Duration class WaitForView( @IdRes private val viewId: Int, private val timeout: Duration ) : ViewAction { override fun getDescription(): String { return "wait up to ${timeout.inWholeMilliseconds} milliseconds to find a view with ID $viewId" } override fun getConstraints(): Matcher { return isRoot() } override fun perform(uiController: UiController, view: View) { uiController.loopMainThreadUntilIdle() val endTime = System.currentTimeMillis() + timeout.inWholeMilliseconds do { for (child in TreeIterables.breadthFirstViewTraversal(view)) { if (withId(viewId).matches(child)) return } uiController.loopMainThreadForAtLeast(500) } while (System.currentTimeMillis() < endTime) throw PerformException.Builder() .withActionDescription(description) .withCause(TimeoutException("Waited $timeout milliseconds")) .withViewDescription(HumanReadables.describe(view)) .build() } } fun waitForView(@IdRes viewId: Int, timeout: Duration) = WaitForView(viewId, timeout) ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/utils/filter/RequiresScreenshotMode.kt ================================================ package io.github.drumber.kitsune.utils.filter import androidx.test.filters.CustomFilter @Retention(AnnotationRetention.RUNTIME) @Target(AnnotationTarget.FUNCTION) @CustomFilter(filterClass = ScreenshotModeCustomFilter::class) annotation class RequiresScreenshotMode ================================================ FILE: app/src/androidTest/java/io/github/drumber/kitsune/utils/filter/ScreenshotModeCustomFilter.kt ================================================ package io.github.drumber.kitsune.utils.filter import androidx.test.filters.AbstractFilter import io.github.drumber.kitsune.BuildConfig import org.junit.runner.Description class ScreenshotModeCustomFilter : AbstractFilter() { override fun describe(): String { return "only run test if BuildConfig property 'SCREENSHOT_MODE_ENABLED' is 'true'" } override fun evaluateTest(description: Description?): Boolean { return BuildConfig.SCREENSHOT_MODE_ENABLED } } ================================================ FILE: app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: app/src/instrumented/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/KitsuneApplication.kt ================================================ package io.github.drumber.kitsune import android.app.Application import androidx.appcompat.app.AppCompatDelegate import com.algolia.instantsearch.core.InstantSearchTelemetry import com.chibatching.kotpref.Kotpref import com.chibatching.kotpref.livedata.asLiveData import io.github.drumber.kitsune.data.presentation.model.appupdate.UpdateCheckResult import io.github.drumber.kitsune.data.repository.AppUpdateRepository import io.github.drumber.kitsune.di.appModule import io.github.drumber.kitsune.domain.auth.IsUserLoggedInUseCase import io.github.drumber.kitsune.domain.user.UpdateLocalUserUseCase import io.github.drumber.kitsune.notification.NotificationChannels import io.github.drumber.kitsune.notification.Notifications import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.logI import io.github.drumber.kitsune.util.logW import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.koin.android.ext.android.get import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.android.ext.koin.androidLogger import org.koin.core.context.startKoin import org.koin.core.logger.Level import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.milliseconds class KitsuneApplication : Application() { private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) override fun onCreate() { super.onCreate() try { NotificationChannels.registerNotificationChannels(this) } catch (e: Exception) { logE("Failed to register notification channels.", e) } startKoin { androidLogger(if (BuildConfig.DEBUG) Level.DEBUG else Level.INFO) androidContext(this@KitsuneApplication) modules(appModule) } performMigrations() Kotpref.init(this) KitsunePref.asLiveData(KitsunePref::darkMode).observeForever { AppCompatDelegate.setDefaultNightMode(it.toInt()) } // opt out of algolia telemetry InstantSearchTelemetry.shared.enabled = false initLoggedInUser() if (!BuildConfig.SCREENSHOT_MODE_ENABLED && KitsunePref.checkForUpdatesOnStart) { checkForNewVersion() } } private fun performMigrations() { // 1.8.0 - replaced ResourceDatabase with LocalDatabase listOf("resources.db", "resources.db-shm", "resources.db-wal").forEach { val databaseFile = getDatabasePath(it) if (databaseFile.isFile) { try { val isDeleted = databaseFile.delete() if (isDeleted) logI("[Migration-1.8.0] Deleted database file '${databaseFile.absolutePath}'.") else logW("[Migration-1.8.0] Failed to delete database file '${databaseFile.absolutePath}'.") } catch (e: Exception) { logE( "[Migration-1.8.0] Error while deleting database file '${databaseFile.absolutePath}'.", e ) } } } } private fun initLoggedInUser() { val isUserLoggedIn: IsUserLoggedInUseCase by inject() if (isUserLoggedIn()) { val updateLocalUser: UpdateLocalUserUseCase by inject() applicationScope.launch(Dispatchers.IO) { updateLocalUser() } } } private fun checkForNewVersion() { val lastUpdateCheck = KitsunePref.lastUpdateCheck val now = System.currentTimeMillis().milliseconds if (lastUpdateCheck != -1L && now.minus(lastUpdateCheck.milliseconds) < 24.hours) { logD("Skipping update check, last check was: $lastUpdateCheck") return } applicationScope.launch { val updateChecker: AppUpdateRepository = get() val result = updateChecker.checkForUpdates(BuildConfig.VERSION_NAME) if (result is UpdateCheckResult.NewVersion) { Notifications.showNewVersion(this@KitsuneApplication, result.release) } KitsunePref.lastUpdateCheck = System.currentTimeMillis() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/KitsuneGlideModule.kt ================================================ package io.github.drumber.kitsune import android.content.Context import android.graphics.Bitmap import com.bumptech.glide.Glide import com.bumptech.glide.GlideBuilder import com.bumptech.glide.Registry import com.bumptech.glide.RequestBuilder import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader import com.bumptech.glide.load.MultiTransformation import com.bumptech.glide.load.Transformation import com.bumptech.glide.load.model.GlideUrl import com.bumptech.glide.load.resource.bitmap.CenterCrop import com.bumptech.glide.module.AppGlideModule import com.bumptech.glide.request.RequestOptions import okhttp3.OkHttpClient import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.qualifier.named import java.io.InputStream @GlideModule class KitsuneGlideModule : AppGlideModule(), KoinComponent { override fun applyOptions(context: Context, builder: GlideBuilder) { val multiTransform = MultiTransformation( buildList { add(CenterCrop()) if (BuildConfig.SCREENSHOT_MODE_ENABLED) { val blurTransformation = Class.forName("jp.wasabeef.glide.transformations.BlurTransformation") .getConstructor(Integer.TYPE, Integer.TYPE) .newInstance(15, 2) add(blurTransformation as Transformation) } } ) builder.setDefaultRequestOptions(RequestOptions.bitmapTransform(multiTransform)) } override fun registerComponents(context: Context, glide: Glide, registry: Registry) { // replace Glides default OkHttpClient val okHttpClient: OkHttpClient = get(named("images")) registry.replace(GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(okHttpClient)) } } fun RequestBuilder.addTransform(vararg transformations: Transformation) = with(this) { val oldTransforms = this.transformations .filterKeys { it.isAssignableFrom(Bitmap::class.java) } .map { it.value as Transformation } transform(MultiTransformation(oldTransforms + transformations)) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/AppTheme.kt ================================================ package io.github.drumber.kitsune.constants import androidx.annotation.StyleRes import io.github.drumber.kitsune.R enum class AppTheme(@StyleRes val themeRes: Int, @StyleRes val blackThemeRes: Int) { DEFAULT(R.style.Theme_Kitsune_DayNight, R.style.Theme_Kitsune_DayNight_Black), PURPLE(R.style.Theme_Kitsune_DayNight_Purple, R.style.Theme_Kitsune_DayNight_Purple_Black), BLUE(R.style.Theme_Kitsune_DayNight_Blue, R.style.Theme_Kitsune_DayNight_Blue_Black), GREEN(R.style.Theme_Kitsune_DayNight_Green, R.style.Theme_Kitsune_DayNight_Green_Black), } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/Defaults.kt ================================================ package io.github.drumber.kitsune.constants object Defaults { /** The minimum of required fields to display resources in a collection, e.g. in RecyclerView. */ val MINIMUM_COLLECTION_FIELDS get() = arrayOf("slug", "titles", "canonicalTitle", "posterImage", "coverImage") val MINIMUM_CHARACTER_FIELDS get() = arrayOf("slug", "name", "image") } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/GitHub.kt ================================================ package io.github.drumber.kitsune.constants object GitHub { const val API_URL = "https://api.github.com/repos/drumber/kitsune/" } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/IntentAction.kt ================================================ package io.github.drumber.kitsune.constants object IntentAction { // Actions for app shortcuts const val SHORTCUT_LIBRARY = "io.github.drumber.kitsune.LIBRARY" const val SHORTCUT_SEARCH = "io.github.drumber.kitsune.SEARCH" const val SHORTCUT_SETTINGS = "io.github.drumber.kitsune.SETTINGS" // Actions for widget const val OPEN_MEDIA = "io.github.drumber.kitsune.OPEN_MEDIA" const val OPEN_LIBRARY = "io.github.drumber.kitsune.OPEN_LIBRARY" } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/Kitsu.kt ================================================ package io.github.drumber.kitsune.constants object Kitsu { const val DEFAULT_PAGE_OFFSET = 0 const val DEFAULT_PAGE_SIZE = 10 const val DEFAULT_PAGE_SIZE_LIBRARY = 30 const val ALGOLIA_APP_ID = "AWQO5J657S" const val API_HOST = "kitsu.app" const val API_URL = "https://$API_HOST/api/edge/" const val OAUTH_URL = "https://$API_HOST/api/oauth/" const val BASE_URL = "https://$API_HOST" const val USER_URL_PREFIX = "$BASE_URL/users/" const val ANIME_URL_PREFIX = "$BASE_URL/anime/" const val MANGA_URL_PREFIX = "$BASE_URL/manga/" } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/LibraryWidget.kt ================================================ package io.github.drumber.kitsune.constants object LibraryWidget { const val MAX_ITEM_COUNT = 10 } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/MediaItemSize.kt ================================================ package io.github.drumber.kitsune.constants import androidx.annotation.DimenRes import io.github.drumber.kitsune.R enum class MediaItemSize( @DimenRes val widthRes: Int, @DimenRes val heightRes: Int ) { SMALL(R.dimen.media_item_width_small, R.dimen.media_item_height_small), MEDIUM(R.dimen.media_item_width_medium, R.dimen.media_item_height_medium), LARGE(R.dimen.media_item_width_large, R.dimen.media_item_height_large) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/Repository.kt ================================================ package io.github.drumber.kitsune.constants object Repository { const val MAX_CACHED_ITEMS = 600 } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/SortFilter.kt ================================================ package io.github.drumber.kitsune.constants import io.github.drumber.kitsune.R enum class SortFilter(val queryParam: String) { POPULARITY_DESC("-user_count"), POPULARITY_ASC("user_count"), AVERAGE_RATING_DESC("-average_rating"), AVERAGE_RATING_ASC("average_rating"), DATE_DESC("-start_date"), DATE_ASC("start_date"); companion object { fun fromQueryParam(queryParam: String?): SortFilter? { if(queryParam != null) { entries.forEach { if(it.queryParam.startsWith(queryParam)) { return it } } } return null } } } enum class CategorySortFilter(val queryParam: String) { TOTAL_MEDIA_COUNT_DESC("-total_media_count"), TOTAL_MEDIA_COUNT_ASC("total_media_count") } fun SortFilter.toStringRes() = when (this) { SortFilter.POPULARITY_DESC -> R.string.sort_popularity_desc SortFilter.POPULARITY_ASC -> R.string.sort_popularity_asc SortFilter.AVERAGE_RATING_DESC -> R.string.sort_average_rating_desc SortFilter.AVERAGE_RATING_ASC -> R.string.sort_average_rating_asc SortFilter.DATE_DESC -> R.string.sort_release_date_desc SortFilter.DATE_ASC -> R.string.sort_release_date_asc } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/constants/StreamingLogo.kt ================================================ package io.github.drumber.kitsune.constants import androidx.annotation.DrawableRes import io.github.drumber.kitsune.R enum class StreamingLogo(@DrawableRes val drawable: Int) { Amazon(R.drawable.ic_amazon), Animelab(R.drawable.ic_animelab), CONtv(R.drawable.ic_contv), Crunchyroll(R.drawable.ic_crunchyroll), Funimation(R.drawable.ic_funimation), HIDIVE(R.drawable.ic_hidive), Hulu(R.drawable.ic_hulu), Netflix(R.drawable.ic_netflix), TubiTV(R.drawable.ic_tubitv), VRV(R.drawable.ic_vrv), YouTube(R.drawable.ic_youtube) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/Filter.kt ================================================ package io.github.drumber.kitsune.data.common import io.github.drumber.kitsune.constants.Kitsu data class Filter(val options: FilterOptions = mutableMapOf()) { /** Defines how much of data to receive. This is only used for some lists, like trending. */ fun limit(limit: Int) = put("limit", limit) /** Defines how much of a resource to receive. */ fun pageLimit(limit: Int = Kitsu.DEFAULT_PAGE_SIZE) = put("page[limit]", limit) /** The index of the first resource to receive. */ fun pageOffset(offset: Int = Kitsu.DEFAULT_PAGE_OFFSET) = put("page[offset]", offset) /** Return only the specified fields of a resource. */ fun fields(type: String, vararg fields: String) = put("fields[$type]", fields.joinToString(",")) /** Sort by the specified attributes. Prepend a `-` for descending order. */ fun sort(vararg attributes: String) = put("sort", attributes.joinToString(",")) /** Filter by certain matching attributes or relationships. */ fun filter(attribute: String, value: String) = put("filter[$attribute]", value) /** Checks if there is a filter applied for the given attribute name. */ fun hasFilterAttribute(attribute: String) = options.containsKey("filter[$attribute]") fun include(vararg relationships: String) = put("include", relationships.joinToString(",")) private fun put(key: String, value: Any): Filter { options[key] = value.toString() return this } } typealias FilterOptions = MutableMap fun FilterOptions.toFilter() = Filter(this) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/Image.kt ================================================ package io.github.drumber.kitsune.data.common data class Image( val tiny: String?, val small: String?, val medium: String?, val large: String?, val original: String?, val meta: ImageMeta? ) { fun smallOrHigher(): String? { return small ?: medium ?: large ?: original } fun largeOrDown(): String? { return large ?: medium ?: small ?: tiny } fun originalOrDown(): String? { return original ?: largeOrDown() } } data class ImageMeta(val dimensions: ImageDimensions?) data class ImageDimensions( val tiny: ImageDimension?, val small: ImageDimension?, val medium: ImageDimension?, val large: ImageDimension? ) data class ImageDimension(val width: Int?, val height: Int?) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/Titles.kt ================================================ package io.github.drumber.kitsune.data.common typealias Titles = Map val Titles.en get() = get("en") val Titles.enUs get() = get("en_us") val Titles.enJp get() = get("en_jp") val Titles.jaJp get() = get("ja_jp") private val commonTitleKeys = listOf("en", "en_jp", "ja_jp") fun Titles.withoutCommonTitles() = filterKeys { !commonTitleKeys.contains(it) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/exception/InvalidDataException.kt ================================================ package io.github.drumber.kitsune.data.common.exception class InvalidDataException(message: String): Exception(message) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/exception/NoDataException.kt ================================================ package io.github.drumber.kitsune.data.common.exception class NoDataException : Exception { constructor() : super() constructor(message: String) : super(message) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/exception/NotFoundException.kt ================================================ package io.github.drumber.kitsune.data.common.exception class NotFoundException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/exception/ResourceUpdateFailed.kt ================================================ package io.github.drumber.kitsune.data.common.exception class ResourceUpdateFailed : Exception { constructor() : super() constructor(message: String) : super(message) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/exception/SearchProviderUnavailableException.kt ================================================ package io.github.drumber.kitsune.data.common.exception class SearchProviderUnavailableException(message: String? = null): Exception(message) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/library/LibraryEntryKind.kt ================================================ package io.github.drumber.kitsune.data.common.library enum class LibraryEntryKind { All, Anime, Manga } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/media/AgeRating.kt ================================================ package io.github.drumber.kitsune.data.common.media enum class AgeRating { G, PG, R, R18 } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/media/AnimeSubtype.kt ================================================ package io.github.drumber.kitsune.data.common.media enum class AnimeSubtype { ONA, OVA, TV, Movie, Music, Special } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/media/MangaSubtype.kt ================================================ package io.github.drumber.kitsune.data.common.media enum class MangaSubtype { Doujin, Manga, Manhua, Manhwa, Novel, Oel, Oneshot } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/media/MediaType.kt ================================================ package io.github.drumber.kitsune.data.common.media enum class MediaType { Anime, Manga } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/media/RatingFrequencies.kt ================================================ package io.github.drumber.kitsune.data.common.media data class RatingFrequencies( val r2: String?, val r3: String?, val r4: String?, val r5: String?, val r6: String?, val r7: String?, val r8: String?, val r9: String?, val r10: String?, val r11: String?, val r12: String?, val r13: String?, val r14: String?, val r15: String?, val r16: String?, val r17: String?, val r18: String?, val r19: String?, val r20: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/media/ReleaseStatus.kt ================================================ package io.github.drumber.kitsune.data.common.media enum class ReleaseStatus { Current, Finished, TBA, Unreleased, Upcoming } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/common/user/UserThemePreference.kt ================================================ package io.github.drumber.kitsune.data.common.user enum class UserThemePreference { Dark, Light } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/AlgoliaMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.ImageDimension import io.github.drumber.kitsune.data.common.ImageDimensions import io.github.drumber.kitsune.data.common.ImageMeta import io.github.drumber.kitsune.data.common.media.AnimeSubtype import io.github.drumber.kitsune.data.common.media.MangaSubtype import io.github.drumber.kitsune.data.presentation.model.algolia.AlgoliaKey import io.github.drumber.kitsune.data.presentation.model.algolia.AlgoliaKeyCollection import io.github.drumber.kitsune.data.presentation.model.character.CharacterSearchResult import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.source.network.algolia.model.NetworkAlgoliaKey import io.github.drumber.kitsune.data.source.network.algolia.model.NetworkAlgoliaKeyCollection import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaCharacterSearchResult import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaDimension import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaDimensions import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaImage import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaImageMeta import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaMediaSearchKind import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaMediaSearchResult object AlgoliaMapper { fun NetworkAlgoliaKeyCollection.toAlgoliaKeyCollection() = AlgoliaKeyCollection( users = users?.toAlgoliaKey(), posts = posts?.toAlgoliaKey(), media = media?.toAlgoliaKey(), groups = groups?.toAlgoliaKey(), characters = characters?.toAlgoliaKey() ) fun NetworkAlgoliaKey.toAlgoliaKey() = AlgoliaKey( key = key.require(), index = index ) fun AlgoliaMediaSearchResult.toMedia() = when (kind) { AlgoliaMediaSearchKind.Anime -> toAnime() AlgoliaMediaSearchKind.Manga -> toManga() } private fun AlgoliaMediaSearchResult.toAnime() = Anime( id = id.toString(), subtype = animeSubtypeFromString(subtype), slug = slug, titles = titles, canonicalTitle = canonicalTitle, posterImage = posterImage?.toImage(), abbreviatedTitles = null, ageRating = null, ageRatingGuide = null, averageRating = null, animeProduction = null, categories = null, coverImage = null, description = null, endDate = null, episodeCount = null, episodeLength = null, favoritesCount = null, mediaRelationships = null, nextRelease = null, nsfw = null, popularityRank = null, ratingFrequencies = null, ratingRank = null, startDate = null, status = null, streamingLinks = null, tba = null, totalLength = null, userCount = null, youtubeVideoId = null ) private fun AlgoliaMediaSearchResult.toManga() = Manga( id = id.toString(), subtype = mangaSubtypeFromString(subtype), slug = slug, titles = titles, canonicalTitle = canonicalTitle, posterImage = posterImage?.toImage(), userCount = null, totalLength = null, tba = null, status = null, startDate = null, ratingRank = null, ratingFrequencies = null, popularityRank = null, nsfw = null, nextRelease = null, mediaRelationships = null, favoritesCount = null, endDate = null, description = null, coverImage = null, categories = null, averageRating = null, ageRatingGuide = null, ageRating = null, abbreviatedTitles = null, chapterCount = null, serialization = null, volumeCount = null ) fun AlgoliaCharacterSearchResult.toCharacterSearchResult() = CharacterSearchResult( id = id.toString(), slug = slug, name = canonicalName, image = image?.toImage(), primaryMediaTitle = primaryMedia ) fun AlgoliaImage.toImage() = Image( tiny, small, medium, large, original, meta?.toImageMeta() ) fun AlgoliaImageMeta.toImageMeta() = ImageMeta(dimensions?.toDimensions()) fun AlgoliaDimensions.toDimensions() = ImageDimensions( tiny?.toDimension(), small?.toDimension(), medium?.toDimension(), large?.toDimension() ) fun AlgoliaDimension.toDimension() = ImageDimension(width, height) private fun animeSubtypeFromString(subtype: String?) = AnimeSubtype.entries .find { it.name.equals(subtype, true) } private fun mangaSubtypeFromString(subtype: String?) = MangaSubtype.entries .find { it.name.equals(subtype, true) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/AppUpdateMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.presentation.model.appupdate.AppRelease import io.github.drumber.kitsune.data.source.network.appupdate.model.NetworkGitHubRelease object AppUpdateMapper { fun NetworkGitHubRelease.toAppRelease() = AppRelease( version = version, url = url ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/AuthMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken import io.github.drumber.kitsune.data.source.network.auth.model.NetworkAccessToken object AuthMapper { fun NetworkAccessToken.toLocalAccessToken() = LocalAccessToken( accessToken = accessToken.require(), createdAt = createdAt.require(), expiresIn = expiresIn.require(), refreshToken = refreshToken.require() ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/CharacterMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.mapper.MediaMapper.toMedia import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.presentation.model.character.CharacterSearchResult import io.github.drumber.kitsune.data.presentation.model.character.MediaCharacter import io.github.drumber.kitsune.data.presentation.model.character.MediaCharacterRole import io.github.drumber.kitsune.data.source.local.character.LocalCharacter import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter import io.github.drumber.kitsune.data.source.network.character.model.NetworkMediaCharacter import io.github.drumber.kitsune.data.source.network.character.model.NetworkMediaCharacterRole object CharacterMapper { //********************************************************************************************// // From Network //********************************************************************************************// fun NetworkCharacter.toCharacter() = Character( id = id.require(), slug = slug, name = name, names = names, otherNames = otherNames, malId = malId, description = description, image = image, mediaCharacters = mediaCharacters?.map { it.toMediaCharacter() } ) fun NetworkCharacter.toLocalCharacter() = LocalCharacter( id = id.require(), slug = slug, name = name, names = names, otherNames = otherNames, malId = malId, description = description, image = image ) fun CharacterSearchResult.toCharacter() = Character( id = id, slug = slug, name = name, names = null, otherNames = null, malId = null, description = null, image = image, mediaCharacters = null ) private fun NetworkMediaCharacter.toMediaCharacter() = MediaCharacter( id = id.require(), role = role?.toMediaCharacterRole(), media = media?.toMedia() ) private fun NetworkMediaCharacterRole.toMediaCharacterRole() = when (this) { NetworkMediaCharacterRole.MAIN -> MediaCharacterRole.MAIN NetworkMediaCharacterRole.SUPPORTING -> MediaCharacterRole.SUPPORTING NetworkMediaCharacterRole.RECURRING -> MediaCharacterRole.RECURRING NetworkMediaCharacterRole.CAMEO -> MediaCharacterRole.CAMEO } //********************************************************************************************// // From Local //********************************************************************************************// fun LocalCharacter.toCharacter() = Character( id = id.require(), slug = slug, name = name, names = names, otherNames = otherNames, malId = malId, description = description, image = image, mediaCharacters = null ) fun LocalCharacter.toNetworkCharacter() = NetworkCharacter( id = id, slug = slug, name = name, names = names, otherNames = otherNames, malId = malId, description = description, image = image, mediaCharacters = null ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/ImageMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.ImageDimension import io.github.drumber.kitsune.data.common.ImageDimensions import io.github.drumber.kitsune.data.common.ImageMeta import io.github.drumber.kitsune.data.source.local.library.model.LocalDimension import io.github.drumber.kitsune.data.source.local.library.model.LocalDimensions import io.github.drumber.kitsune.data.source.local.library.model.LocalImage import io.github.drumber.kitsune.data.source.local.library.model.LocalImageMeta object ImageMapper { fun Image.toLocalImage() = LocalImage( tiny = tiny, small = small, medium = medium, large = large, original = original, meta = meta?.toLocalImageMeta() ) fun ImageMeta.toLocalImageMeta() = LocalImageMeta( dimensions = dimensions?.toLocalDimensions() ) fun ImageDimensions.toLocalDimensions() = LocalDimensions( tiny = tiny?.toLocalDimension(), small = small?.toLocalDimension(), medium = medium?.toLocalDimension(), large = large?.toLocalDimension() ) fun ImageDimension.toLocalDimension() = LocalDimension( width = width, height = height ) fun LocalImage.toImage() = Image( tiny = tiny, small = small, medium = medium, large = large, original = original, meta = meta?.toImageMeta() ) fun LocalImageMeta.toImageMeta() = ImageMeta( dimensions = dimensions?.toImageDimensions() ) fun LocalDimensions.toImageDimensions() = ImageDimensions( tiny = tiny?.toImageDimension(), small = small?.toImageDimension(), medium = medium?.toImageDimension(), large = large?.toImageDimension() ) fun LocalDimension.toImageDimension() = ImageDimension( width = width, height = height ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/LibraryMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.mapper.ImageMapper.toImage import io.github.drumber.kitsune.data.mapper.ImageMapper.toLocalImage import io.github.drumber.kitsune.data.mapper.MediaMapper.toAnime import io.github.drumber.kitsune.data.mapper.MediaMapper.toAnimeSubtype import io.github.drumber.kitsune.data.mapper.MediaMapper.toManga import io.github.drumber.kitsune.data.mapper.MediaMapper.toMangaSubtype import io.github.drumber.kitsune.data.mapper.MediaMapper.toRatingFrequencies import io.github.drumber.kitsune.data.mapper.MediaMapper.toReleaseStatus import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryModificationState import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.presentation.model.library.ReactionSkip import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntry import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntryModification import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryMedia import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryMedia.MediaType import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryModificationState import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryStatus import io.github.drumber.kitsune.data.source.local.library.model.LocalReactionSkip import io.github.drumber.kitsune.data.source.network.library.model.NetworkLibraryEntry import io.github.drumber.kitsune.data.source.network.library.model.NetworkLibraryStatus import io.github.drumber.kitsune.data.source.network.library.model.NetworkReactionSkip import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga object LibraryMapper { fun LocalLibraryEntry.toLibraryEntry() = LibraryEntry( id = id, updatedAt = updatedAt, startedAt = startedAt, finishedAt = finishedAt, progressedAt = progressedAt, status = status?.toLibraryStatus(), progress = progress, reconsuming = reconsuming, reconsumeCount = reconsumeCount, volumesOwned = volumesOwned, ratingTwenty = ratingTwenty, notes = notes, privateEntry = privateEntry, reactionSkipped = reactionSkipped?.toReactionSkip(), media = media?.toMedia() ) fun NetworkLibraryEntry.toLibraryEntry() = LibraryEntry( id = id.require(), updatedAt = updatedAt, startedAt = startedAt, finishedAt = finishedAt, progressedAt = progressedAt, status = status?.toLibraryStatus(), progress = progress, reconsuming = reconsuming, reconsumeCount = reconsumeCount, volumesOwned = volumesOwned, ratingTwenty = ratingTwenty, notes = notes, privateEntry = privateEntry, reactionSkipped = reactionSkipped?.toReactionSkip(), media = when { anime != null -> anime.toAnime() manga != null -> manga.toManga() else -> null } ) fun NetworkLibraryEntry.toLocalLibraryEntry() = LocalLibraryEntry( id = id.require(), updatedAt = updatedAt, startedAt = startedAt, finishedAt = finishedAt, progressedAt = progressedAt, status = status?.toLocalLibraryStatus(), progress = progress, reconsuming = reconsuming, reconsumeCount = reconsumeCount, volumesOwned = volumesOwned, ratingTwenty = ratingTwenty, notes = notes, privateEntry = privateEntry, reactionSkipped = reactionSkipped?.toLocalReactionSkip(), media = when { anime != null -> anime.toLocalLibraryMedia() manga != null -> manga.toLocalLibraryMedia() else -> null } ) fun LocalLibraryEntryModification.toLibraryEntryModification() = LibraryEntryModification( id = id, createTime = createTime, state = state.toLibraryModificationState(), startedAt = startedAt, finishedAt = finishedAt, status = status?.toLibraryStatus(), progress = progress, reconsumeCount = reconsumeCount, volumesOwned = volumesOwned, ratingTwenty = ratingTwenty, notes = notes, privateEntry = privateEntry, ) fun LibraryEntryModification.toLocalLibraryEntryModification() = LocalLibraryEntryModification( id = id, createTime = createTime, state = state.toLocalLibraryModificationState(), startedAt = startedAt, finishedAt = finishedAt, status = status?.toLocalLibraryStatus(), progress = progress, reconsumeCount = reconsumeCount, volumesOwned = volumesOwned, ratingTwenty = ratingTwenty, notes = notes, privateEntry = privateEntry, ) fun LocalLibraryMedia.toMedia() = when (type) { MediaType.Anime -> toAnime() MediaType.Manga -> toManga() } fun LocalLibraryMedia.toAnime() = Anime( id = id, slug = null, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = ratingFrequencies, userCount = null, favoritesCount = null, popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status, ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = nsfw, posterImage = posterImage?.toImage(), coverImage = coverImage?.toImage(), totalLength = totalLength, episodeCount = episodeCount, episodeLength = episodeLength, youtubeVideoId = null, subtype = animeSubtype, categories = null, animeProduction = null, streamingLinks = null, mediaRelationships = null ) fun LocalLibraryMedia.toManga() = Manga( id = id, slug = null, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = ratingFrequencies, userCount = null, favoritesCount = null, popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status, ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = nsfw, posterImage = posterImage?.toImage(), coverImage = coverImage?.toImage(), totalLength = totalLength, chapterCount = chapterCount, volumeCount = volumeCount, subtype = mangaSubtype, serialization = serialization, categories = null, mediaRelationships = null ) fun NetworkAnime.toLocalLibraryMedia() = LocalLibraryMedia( id = id, type = MediaType.Anime, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = ratingFrequencies?.toRatingFrequencies(), popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status?.toReleaseStatus(), ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = nsfw, posterImage = posterImage?.toLocalImage(), coverImage = coverImage?.toLocalImage(), animeSubtype = subtype?.toAnimeSubtype(), totalLength = totalLength, episodeCount = episodeCount, episodeLength = episodeLength, mangaSubtype = null, chapterCount = null, volumeCount = null, serialization = null ) fun NetworkManga.toLocalLibraryMedia() = LocalLibraryMedia( id = id, type = MediaType.Manga, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = ratingFrequencies?.toRatingFrequencies(), popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status?.toReleaseStatus(), ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = nsfw, posterImage = posterImage?.toLocalImage(), coverImage = coverImage?.toLocalImage(), animeSubtype = null, totalLength = null, episodeCount = null, episodeLength = null, mangaSubtype = subtype?.toMangaSubtype(), chapterCount = chapterCount, volumeCount = volumeCount, serialization = serialization ) fun NetworkReactionSkip.toLocalReactionSkip(): LocalReactionSkip = when (this) { NetworkReactionSkip.Unskipped -> LocalReactionSkip.Unskipped NetworkReactionSkip.Skipped -> LocalReactionSkip.Skipped NetworkReactionSkip.Ignored -> LocalReactionSkip.Ignored } fun NetworkLibraryStatus.toLocalLibraryStatus(): LocalLibraryStatus = when (this) { NetworkLibraryStatus.Current -> LocalLibraryStatus.Current NetworkLibraryStatus.Planned -> LocalLibraryStatus.Planned NetworkLibraryStatus.Completed -> LocalLibraryStatus.Completed NetworkLibraryStatus.OnHold -> LocalLibraryStatus.OnHold NetworkLibraryStatus.Dropped -> LocalLibraryStatus.Dropped } fun NetworkLibraryStatus.toLibraryStatus(): LibraryStatus = when (this) { NetworkLibraryStatus.Current -> LibraryStatus.Current NetworkLibraryStatus.Planned -> LibraryStatus.Planned NetworkLibraryStatus.Completed -> LibraryStatus.Completed NetworkLibraryStatus.OnHold -> LibraryStatus.OnHold NetworkLibraryStatus.Dropped -> LibraryStatus.Dropped } fun LibraryStatus.toLocalLibraryStatus(): LocalLibraryStatus = when (this) { LibraryStatus.Current -> LocalLibraryStatus.Current LibraryStatus.Planned -> LocalLibraryStatus.Planned LibraryStatus.Completed -> LocalLibraryStatus.Completed LibraryStatus.OnHold -> LocalLibraryStatus.OnHold LibraryStatus.Dropped -> LocalLibraryStatus.Dropped } fun LibraryStatus.toNetworkLibraryStatus(): NetworkLibraryStatus = when (this) { LibraryStatus.Current -> NetworkLibraryStatus.Current LibraryStatus.Planned -> NetworkLibraryStatus.Planned LibraryStatus.Completed -> NetworkLibraryStatus.Completed LibraryStatus.OnHold -> NetworkLibraryStatus.OnHold LibraryStatus.Dropped -> NetworkLibraryStatus.Dropped } fun LocalLibraryStatus.toLibraryStatus(): LibraryStatus = when (this) { LocalLibraryStatus.Current -> LibraryStatus.Current LocalLibraryStatus.Planned -> LibraryStatus.Planned LocalLibraryStatus.Completed -> LibraryStatus.Completed LocalLibraryStatus.OnHold -> LibraryStatus.OnHold LocalLibraryStatus.Dropped -> LibraryStatus.Dropped } fun NetworkReactionSkip.toReactionSkip(): ReactionSkip = when (this) { NetworkReactionSkip.Unskipped -> ReactionSkip.Unskipped NetworkReactionSkip.Skipped -> ReactionSkip.Skipped NetworkReactionSkip.Ignored -> ReactionSkip.Ignored } fun LocalReactionSkip.toReactionSkip(): ReactionSkip = when (this) { LocalReactionSkip.Unskipped -> ReactionSkip.Unskipped LocalReactionSkip.Skipped -> ReactionSkip.Skipped LocalReactionSkip.Ignored -> ReactionSkip.Ignored } fun LocalLibraryModificationState.toLibraryModificationState(): LibraryModificationState = when (this) { LocalLibraryModificationState.SYNCHRONIZING -> LibraryModificationState.SYNCHRONIZING LocalLibraryModificationState.NOT_SYNCHRONIZED -> LibraryModificationState.NOT_SYNCHRONIZED } fun LibraryModificationState.toLocalLibraryModificationState(): LocalLibraryModificationState = when (this) { LibraryModificationState.SYNCHRONIZING -> LocalLibraryModificationState.SYNCHRONIZING LibraryModificationState.NOT_SYNCHRONIZED -> LocalLibraryModificationState.NOT_SYNCHRONIZED } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/MapperUtils.kt ================================================ package io.github.drumber.kitsune.data.mapper internal inline fun T?.require(): T { return this ?: throw MappingException("Required value is null") } class MappingException(message: String) : IllegalArgumentException(message) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/MappingMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.presentation.model.mapping.Mapping import io.github.drumber.kitsune.data.source.network.mapping.model.NetworkMapping object MappingMapper { fun NetworkMapping.toMapping() = Mapping( id = id.require(), externalSite = externalSite, externalId = externalId ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/MediaMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.common.media.AnimeSubtype import io.github.drumber.kitsune.data.common.media.MangaSubtype import io.github.drumber.kitsune.data.common.media.RatingFrequencies import io.github.drumber.kitsune.data.common.media.ReleaseStatus import io.github.drumber.kitsune.data.mapper.CharacterMapper.toCharacter import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.presentation.model.media.category.Category import io.github.drumber.kitsune.data.presentation.model.media.production.AnimeProduction import io.github.drumber.kitsune.data.presentation.model.media.production.AnimeProductionRole import io.github.drumber.kitsune.data.presentation.model.media.production.Casting import io.github.drumber.kitsune.data.presentation.model.media.production.Person import io.github.drumber.kitsune.data.presentation.model.media.production.Producer import io.github.drumber.kitsune.data.presentation.model.media.relationship.MediaRelationship import io.github.drumber.kitsune.data.presentation.model.media.relationship.MediaRelationshipRole import io.github.drumber.kitsune.data.presentation.model.media.streamer.Streamer import io.github.drumber.kitsune.data.presentation.model.media.streamer.StreamingLink import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnimeSubtype import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import io.github.drumber.kitsune.data.source.network.media.model.NetworkMangaSubtype import io.github.drumber.kitsune.data.source.network.media.model.NetworkMedia import io.github.drumber.kitsune.data.source.network.media.model.NetworkRatingFrequencies import io.github.drumber.kitsune.data.source.network.media.model.NetworkReleaseStatus import io.github.drumber.kitsune.data.source.network.media.model.category.NetworkCategory import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkAnimeProduction import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkAnimeProductionRole import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkCasting import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkPerson import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkProducer import io.github.drumber.kitsune.data.source.network.media.model.relationship.NetworkMediaRelationship import io.github.drumber.kitsune.data.source.network.media.model.relationship.NetworkMediaRelationshipRole import io.github.drumber.kitsune.data.source.network.media.model.streamer.NetworkStreamer import io.github.drumber.kitsune.data.source.network.media.model.streamer.NetworkStreamingLink object MediaMapper { fun NetworkMedia.toMedia(): Media = when (this) { is NetworkAnime -> toAnime() is NetworkManga -> toManga() } fun NetworkAnime.toAnime() = Anime( id = id, slug = slug, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = ratingFrequencies?.toRatingFrequencies(), userCount = userCount, favoritesCount = favoritesCount, popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status?.toReleaseStatus(), ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = nsfw, posterImage = posterImage, coverImage = coverImage, totalLength = totalLength, episodeCount = episodeCount, episodeLength = episodeLength, youtubeVideoId = youtubeVideoId, subtype = subtype?.toAnimeSubtype(), categories = categories?.map { it.toCategory() }, animeProduction = animeProduction?.map { it.toAnimeProduction() }, streamingLinks = streamingLinks?.map { it.toStreamingLink() }, mediaRelationships = mediaRelationships?.map { it.toMediaRelationship() } ) fun NetworkManga.toManga() = Manga( id = id, slug = slug, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = ratingFrequencies?.toRatingFrequencies(), userCount = userCount, favoritesCount = favoritesCount, popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status?.toReleaseStatus(), ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = nsfw, posterImage = posterImage, coverImage = coverImage, totalLength = totalLength, chapterCount = chapterCount, volumeCount = volumeCount, subtype = subtype?.toMangaSubtype(), serialization = serialization, categories = categories?.map { it.toCategory() }, mediaRelationships = mediaRelationships?.map { it.toMediaRelationship() } ) fun NetworkRatingFrequencies.toRatingFrequencies() = RatingFrequencies( r2 = r2, r3 = r3, r4 = r4, r5 = r5, r6 = r6, r7 = r7, r8 = r8, r9 = r9, r10 = r10, r11 = r11, r12 = r12, r13 = r13, r14 = r14, r15 = r15, r16 = r16, r17 = r17, r18 = r18, r19 = r19, r20 = r20 ) fun NetworkReleaseStatus.toReleaseStatus(): ReleaseStatus = when (this) { NetworkReleaseStatus.Current -> ReleaseStatus.Current NetworkReleaseStatus.Finished -> ReleaseStatus.Finished NetworkReleaseStatus.TBA -> ReleaseStatus.TBA NetworkReleaseStatus.Unreleased -> ReleaseStatus.Unreleased NetworkReleaseStatus.Upcoming -> ReleaseStatus.Upcoming } fun NetworkAnimeSubtype.toAnimeSubtype(): AnimeSubtype = when (this) { NetworkAnimeSubtype.ONA -> AnimeSubtype.ONA NetworkAnimeSubtype.OVA -> AnimeSubtype.OVA NetworkAnimeSubtype.TV -> AnimeSubtype.TV NetworkAnimeSubtype.Movie -> AnimeSubtype.Movie NetworkAnimeSubtype.Music -> AnimeSubtype.Music NetworkAnimeSubtype.Special -> AnimeSubtype.Special } fun NetworkMangaSubtype.toMangaSubtype(): MangaSubtype = when (this) { NetworkMangaSubtype.Doujin -> MangaSubtype.Doujin NetworkMangaSubtype.Manga -> MangaSubtype.Manga NetworkMangaSubtype.Manhua -> MangaSubtype.Manhua NetworkMangaSubtype.Manhwa -> MangaSubtype.Manhwa NetworkMangaSubtype.Novel -> MangaSubtype.Novel NetworkMangaSubtype.Oel -> MangaSubtype.Oel NetworkMangaSubtype.Oneshot -> MangaSubtype.Oneshot } //********************************************************************************************// // Category //********************************************************************************************// fun NetworkCategory.toCategory() = Category( id = id.require(), slug = slug, title = title, description = description, nsfw = nsfw, totalMediaCount = totalMediaCount, childCount = childCount ) //********************************************************************************************// // Production //********************************************************************************************// fun NetworkAnimeProduction.toAnimeProduction() = AnimeProduction( id = id.require(), role = role?.toAnimeProductionRole(), producer = producer?.toProducer() ) fun NetworkAnimeProductionRole.toAnimeProductionRole(): AnimeProductionRole = when (this) { NetworkAnimeProductionRole.Licensor -> AnimeProductionRole.Licensor NetworkAnimeProductionRole.Producer -> AnimeProductionRole.Producer NetworkAnimeProductionRole.Studio -> AnimeProductionRole.Studio } fun NetworkProducer.toProducer() = Producer( id = id.require(), slug = slug, name = name ) fun NetworkCasting.toCasting() = Casting( id = id.require(), role = role, voiceActor = voiceActor, featured = featured, language = language, character = character?.toCharacter(), person = person?.toPerson() ) fun NetworkPerson.toPerson() = Person( id = id.require(), name = name, description = description, image = image ) //********************************************************************************************// // Relationship //********************************************************************************************// fun NetworkMediaRelationship.toMediaRelationship() = MediaRelationship( id = id.require(), role = role?.toMediaRelationshipRole(), media = media?.toMedia() ) fun NetworkMediaRelationshipRole.toMediaRelationshipRole(): MediaRelationshipRole = when (this) { NetworkMediaRelationshipRole.Sequel -> MediaRelationshipRole.Sequel NetworkMediaRelationshipRole.Prequel -> MediaRelationshipRole.Prequel NetworkMediaRelationshipRole.AlternativeSetting -> MediaRelationshipRole.AlternativeSetting NetworkMediaRelationshipRole.AlternativeVersion -> MediaRelationshipRole.AlternativeVersion NetworkMediaRelationshipRole.SideStory -> MediaRelationshipRole.SideStory NetworkMediaRelationshipRole.ParentStory -> MediaRelationshipRole.ParentStory NetworkMediaRelationshipRole.Summary -> MediaRelationshipRole.Summary NetworkMediaRelationshipRole.FullStory -> MediaRelationshipRole.FullStory NetworkMediaRelationshipRole.Spinoff -> MediaRelationshipRole.Spinoff NetworkMediaRelationshipRole.Adaptation -> MediaRelationshipRole.Adaptation NetworkMediaRelationshipRole.Character -> MediaRelationshipRole.Character NetworkMediaRelationshipRole.Other -> MediaRelationshipRole.Other } //********************************************************************************************// // Streamer //********************************************************************************************// fun NetworkStreamingLink.toStreamingLink() = StreamingLink( id = id.require(), url = url, subs = subs, dubs = dubs, streamer = streamer?.toStreamer() ) fun NetworkStreamer.toStreamer() = Streamer( id = id.require(), siteName = siteName ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/MediaUnitMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.presentation.model.media.unit.Chapter import io.github.drumber.kitsune.data.presentation.model.media.unit.Episode import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkChapter import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkEpisode import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkMediaUnit object MediaUnitMapper { fun NetworkMediaUnit.toMediaUnit() = when (this) { is NetworkEpisode -> toEpisode() is NetworkChapter -> toChapter() } fun NetworkEpisode.toEpisode() = Episode( id = id.require(), description = description, titles = titles, canonicalTitle = canonicalTitle, number = number, seasonNumber = seasonNumber, relativeNumber = relativeNumber, length = length, airdate = airdate, thumbnail = thumbnail ) fun NetworkChapter.toChapter() = Chapter( id = id.require(), description = description, titles = titles, canonicalTitle = canonicalTitle, number = number, volumeNumber = volumeNumber, length = length, thumbnail = thumbnail, published = published, ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/ProfileLinksMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.mapper.UserMapper.toUser import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLink import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLinkSite import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLinkSite object ProfileLinksMapper { fun NetworkProfileLink.toProfileLink(): ProfileLink = ProfileLink( id = id.require(), url = url, profileLinkSite = profileLinkSite?.toProfileLinkSite(), user = user?.toUser() ) fun NetworkProfileLinkSite.toProfileLinkSite() = ProfileLinkSite( id = id.require(), name = name ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/UserMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.mapper.CharacterMapper.toCharacter import io.github.drumber.kitsune.data.mapper.CharacterMapper.toLocalCharacter import io.github.drumber.kitsune.data.mapper.CharacterMapper.toNetworkCharacter import io.github.drumber.kitsune.data.mapper.MediaMapper.toMedia import io.github.drumber.kitsune.data.mapper.ProfileLinksMapper.toProfileLink import io.github.drumber.kitsune.data.mapper.UserStatsMapper.toUserStats import io.github.drumber.kitsune.data.presentation.model.user.Favorite import io.github.drumber.kitsune.data.presentation.model.user.FavoriteItem import io.github.drumber.kitsune.data.presentation.model.user.User import io.github.drumber.kitsune.data.source.local.user.model.LocalRatingSystemPreference import io.github.drumber.kitsune.data.source.local.user.model.LocalSfwFilterPreference import io.github.drumber.kitsune.data.source.local.user.model.LocalTitleLanguagePreference import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter import io.github.drumber.kitsune.data.source.network.media.model.NetworkMedia import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavorite import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavoriteItem import io.github.drumber.kitsune.data.source.network.user.model.NetworkRatingSystemPreference import io.github.drumber.kitsune.data.source.network.user.model.NetworkSfwFilterPreference import io.github.drumber.kitsune.data.source.network.user.model.NetworkTitleLanguagePreference import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser object UserMapper { //********************************************************************************************// // From Network //********************************************************************************************// fun NetworkUser.toLocalUser() = LocalUser( id = id.require(), createdAt = createdAt, name = name, slug = slug, email = email, title = title, avatar = avatar, coverImage = coverImage, about = about, location = location, gender = gender, birthday = birthday, waifuOrHusbando = waifuOrHusbando, followersCount = followersCount, followingCount = followingCount, country = country, language = language, timeZone = timeZone, theme = theme, sfwFilter = sfwFilter, ratingSystem = ratingSystem?.toLocalRatingSystemPreference(), sfwFilterPreference = sfwFilterPreference?.toLocalSfwFilterPreference(), titleLanguagePreference = titleLanguagePreference?.toLocalTitleLanguagePreference(), waifu = waifu?.toLocalCharacter() ) fun NetworkUser.toUser() = User( id = id.require(), createdAt = createdAt, name = name, slug = slug, title = title, avatar = avatar, coverImage = coverImage, about = about, location = location, gender = gender, birthday = birthday, waifuOrHusbando = waifuOrHusbando, followersCount = followersCount, followingCount = followingCount, country = country, language = language, timeZone = timeZone, stats = stats?.map { it.toUserStats() }, favorites = favorites?.map { it.toFavorite() }, waifu = waifu?.toCharacter(), profileLinks = profileLinks?.map { it.toProfileLink() } ) fun NetworkRatingSystemPreference.toLocalRatingSystemPreference() = when (this) { NetworkRatingSystemPreference.Advanced -> LocalRatingSystemPreference.Advanced NetworkRatingSystemPreference.Regular -> LocalRatingSystemPreference.Regular NetworkRatingSystemPreference.Simple -> LocalRatingSystemPreference.Simple } fun NetworkSfwFilterPreference.toLocalSfwFilterPreference() = when (this) { NetworkSfwFilterPreference.SFW -> LocalSfwFilterPreference.SFW NetworkSfwFilterPreference.NSFW_SOMETIMES -> LocalSfwFilterPreference.NSFW_SOMETIMES NetworkSfwFilterPreference.NSFW_EVERYWHERE -> LocalSfwFilterPreference.NSFW_EVERYWHERE } fun NetworkTitleLanguagePreference.toLocalTitleLanguagePreference() = when (this) { NetworkTitleLanguagePreference.Canonical -> LocalTitleLanguagePreference.Canonical NetworkTitleLanguagePreference.Romanized -> LocalTitleLanguagePreference.Romanized NetworkTitleLanguagePreference.English -> LocalTitleLanguagePreference.English } fun NetworkFavorite.toFavorite(): Favorite = Favorite( id = id.require(), favRank = favRank, item = item?.toFavoriteItem(), user = user?.toUser() ) fun NetworkFavoriteItem.toFavoriteItem(): FavoriteItem = when (this) { is NetworkMedia -> toMedia() is NetworkCharacter -> toCharacter() else -> throw IllegalArgumentException("Unknown favorite item type: ${this.javaClass.name}") } //********************************************************************************************// // From Local //********************************************************************************************// fun LocalUser.toUser() = User( id = id, createdAt = createdAt, name = name, slug = slug, title = title, avatar = avatar, coverImage = coverImage, about = about, location = location, gender = gender, birthday = birthday, waifuOrHusbando = waifuOrHusbando, followersCount = followersCount, followingCount = followingCount, country = country, language = language, timeZone = timeZone, stats = null, favorites = null, waifu = waifu?.toCharacter(), profileLinks = null ) fun LocalUser.toNetworkUser() = NetworkUser( id = id, createdAt = createdAt, name = name, slug = slug, email = email, title = title, avatar = avatar, coverImage = coverImage, about = about, location = location, gender = gender, birthday = birthday, waifuOrHusbando = waifuOrHusbando, country = country, language = language, timeZone = timeZone, theme = theme, sfwFilter = sfwFilter, ratingSystem = ratingSystem?.toNetworkRatingSystemPreference(), sfwFilterPreference = sfwFilterPreference?.toNetworkSfwFilterPreference(), titleLanguagePreference = titleLanguagePreference?.toNetworkTitleLanguagePreference(), waifu = waifu?.toNetworkCharacter(), ) fun LocalRatingSystemPreference.toNetworkRatingSystemPreference() = when (this) { LocalRatingSystemPreference.Advanced -> NetworkRatingSystemPreference.Advanced LocalRatingSystemPreference.Regular -> NetworkRatingSystemPreference.Regular LocalRatingSystemPreference.Simple -> NetworkRatingSystemPreference.Simple } fun LocalSfwFilterPreference.toNetworkSfwFilterPreference() = when (this) { LocalSfwFilterPreference.SFW -> NetworkSfwFilterPreference.SFW LocalSfwFilterPreference.NSFW_SOMETIMES -> NetworkSfwFilterPreference.NSFW_SOMETIMES LocalSfwFilterPreference.NSFW_EVERYWHERE -> NetworkSfwFilterPreference.NSFW_EVERYWHERE } fun LocalTitleLanguagePreference.toNetworkTitleLanguagePreference() = when (this) { LocalTitleLanguagePreference.Canonical -> NetworkTitleLanguagePreference.Canonical LocalTitleLanguagePreference.Romanized -> NetworkTitleLanguagePreference.Romanized LocalTitleLanguagePreference.English -> NetworkTitleLanguagePreference.English } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/mapper/UserStatsMapper.kt ================================================ package io.github.drumber.kitsune.data.mapper import io.github.drumber.kitsune.data.presentation.model.user.stats.AmountConsumedPercentiles import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStats import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStatsData.AmountConsumedData import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStatsData.CategoryBreakdownData import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStatsKind import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkAmountConsumedPercentiles import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkUserStats import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkUserStatsData import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkUserStatsData.NetworkAmountConsumedData import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkUserStatsData.NetworkCategoryBreakdownData import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkUserStatsKind object UserStatsMapper { fun NetworkUserStats.toUserStats() = UserStats( id = id.require(), kind = kind?.toUserStatsKind(), statsData = statsData?.toUserStatsData() ) private fun NetworkUserStatsKind.toUserStatsKind() = when (this) { NetworkUserStatsKind.AnimeActivityHistory -> UserStatsKind.AnimeActivityHistory NetworkUserStatsKind.AnimeAmountConsumed -> UserStatsKind.AnimeAmountConsumed NetworkUserStatsKind.AnimeCategoryBreakdown -> UserStatsKind.AnimeCategoryBreakdown NetworkUserStatsKind.AnimeFavoriteYear -> UserStatsKind.AnimeFavoriteYear NetworkUserStatsKind.MangaActivityHistory -> UserStatsKind.MangaActivityHistory NetworkUserStatsKind.MangaAmountConsumed -> UserStatsKind.MangaAmountConsumed NetworkUserStatsKind.MangaCategoryBreakdown -> UserStatsKind.MangaCategoryBreakdown NetworkUserStatsKind.MangaFavoriteYear -> UserStatsKind.MangaFavoriteYear } private fun NetworkUserStatsData.toUserStatsData() = when (this) { is NetworkAmountConsumedData -> this.toAmountConsumedData() is NetworkCategoryBreakdownData -> this.toCategoryBreakdownData() } private fun NetworkAmountConsumedData.toAmountConsumedData() = AmountConsumedData( time = time, media = media, units = units, completed = completed, percentiles = percentiles?.toAmountConsumedPercentiles(), averageDiffs = averageDiffs?.toAmountConsumedPercentiles() ) private fun NetworkCategoryBreakdownData.toCategoryBreakdownData() = CategoryBreakdownData( total = total, categories = categories ) private fun NetworkAmountConsumedPercentiles.toAmountConsumedPercentiles() = AmountConsumedPercentiles( media = media, units = units, time = time ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/LocalRatingSystemPreference.kt ================================================ package io.github.drumber.kitsune.data.presentation import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.source.local.user.model.LocalRatingSystemPreference fun LocalRatingSystemPreference.getStringRes() = when (this) { LocalRatingSystemPreference.Simple -> R.string.preference_rating_system_simple LocalRatingSystemPreference.Regular -> R.string.preference_rating_system_regular LocalRatingSystemPreference.Advanced -> R.string.preference_rating_system_advanced } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/dto/CharacterDto.kt ================================================ package io.github.drumber.kitsune.data.presentation.dto import android.os.Parcelable import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.presentation.model.character.Character import kotlinx.parcelize.Parcelize @Parcelize data class CharacterDto( val id: String, val slug: String?, val name: String?, val names: Titles?, val otherNames: List?, val malId: Int?, val description: String?, val image: ImageDto? ) : Parcelable fun Character.toCharacterDto() = CharacterDto( id = id, slug = slug, name = name, names = names, otherNames = otherNames, malId = malId, description = description, image = image?.toImageDto() ) fun CharacterDto.toCharacter() = Character( id = id, slug = slug, name = name, names = names, otherNames = otherNames, malId = malId, description = description, image = image?.toImage(), mediaCharacters = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/dto/ImageDto.kt ================================================ package io.github.drumber.kitsune.data.presentation.dto import android.os.Parcelable import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.ImageDimension import io.github.drumber.kitsune.data.common.ImageDimensions import io.github.drumber.kitsune.data.common.ImageMeta import kotlinx.parcelize.Parcelize @Parcelize data class ImageDto( val tiny: String?, val small: String?, val medium: String?, val large: String?, val original: String?, val dimensions: ImageDimensionsDto? ) : Parcelable @Parcelize data class ImageDimensionsDto( val tiny: ImageDimensionDto?, val small: ImageDimensionDto?, val medium: ImageDimensionDto?, val large: ImageDimensionDto? ) : Parcelable @Parcelize data class ImageDimensionDto(val width: Int?, val height: Int?) : Parcelable fun Image.toImageDto() = ImageDto( tiny = tiny, small = small, medium = medium, large = large, original = original, dimensions = meta?.dimensions?.toImageDimensionsDto() ) fun ImageDimensions.toImageDimensionsDto() = ImageDimensionsDto( tiny = tiny?.toImageDimensionDto(), small = small?.toImageDimensionDto(), medium = medium?.toImageDimensionDto(), large = large?.toImageDimensionDto() ) fun ImageDimension.toImageDimensionDto() = ImageDimensionDto( width = width, height = height ) fun ImageDto.toImage() = Image( tiny = tiny, small = small, medium = medium, large = large, original = original, meta = ImageMeta(dimensions?.toImageDimensions()) ) fun ImageDimensionsDto.toImageDimensions() = ImageDimensions( tiny = tiny?.toImageDimension(), small = small?.toImageDimension(), medium = medium?.toImageDimension(), large = large?.toImageDimension() ) fun ImageDimensionDto.toImageDimension() = ImageDimension( width = width, height = height ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/dto/MediaDto.kt ================================================ package io.github.drumber.kitsune.data.presentation.dto import android.os.Parcelable import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.common.media.ReleaseStatus import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.presentation.model.media.Media import kotlinx.parcelize.Parcelize @Parcelize data class MediaDto( val id: String, val type: MediaType, val slug: String?, val description: String?, val titles: Titles?, val canonicalTitle: String?, val abbreviatedTitles: List?, val averageRating: String?, val popularityRank: Int?, val ratingRank: Int?, val startDate: String?, val endDate: String?, val nextRelease: String?, val tba: String?, val status: ReleaseStatus?, val ageRating: AgeRating?, val ageRatingGuide: String?, val posterImage: ImageDto?, val coverImage: ImageDto?, val totalLength: Int? ) : Parcelable fun Media.toMediaDto() = MediaDto( id = id, type = when (this) { is Anime -> MediaType.Anime is Manga -> MediaType.Manga }, slug = slug, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status, ageRating = ageRating, ageRatingGuide = ageRatingGuide, posterImage = posterImage?.toImageDto(), coverImage = coverImage?.toImageDto(), totalLength = totalLength ) fun MediaDto.toMedia(): Media { return when (type) { MediaType.Anime -> toAnime() MediaType.Manga -> toManga() } } private fun MediaDto.toAnime() = Anime( id = id, slug = slug, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = null, userCount = null, favoritesCount = null, popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status, ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = null, posterImage = posterImage?.toImage(), coverImage = coverImage?.toImage(), totalLength = totalLength, episodeCount = null, episodeLength = null, youtubeVideoId = null, subtype = null, categories = null, animeProduction = null, streamingLinks = null, mediaRelationships = null ) private fun MediaDto.toManga() = Manga( id = id, slug = slug, description = description, titles = titles, canonicalTitle = canonicalTitle, abbreviatedTitles = abbreviatedTitles, averageRating = averageRating, ratingFrequencies = null, userCount = null, favoritesCount = null, popularityRank = popularityRank, ratingRank = ratingRank, startDate = startDate, endDate = endDate, nextRelease = nextRelease, tba = tba, status = status, ageRating = ageRating, ageRatingGuide = ageRatingGuide, nsfw = null, posterImage = posterImage?.toImage(), coverImage = coverImage?.toImage(), totalLength = totalLength, chapterCount = null, volumeCount = null, subtype = null, serialization = null, categories = null, mediaRelationships = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/dto/MediaUnitDto.kt ================================================ package io.github.drumber.kitsune.data.presentation.dto import android.os.Parcelable import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.presentation.dto.MediaUnitDto.UnitType import io.github.drumber.kitsune.data.presentation.model.media.unit.Chapter import io.github.drumber.kitsune.data.presentation.model.media.unit.Episode import io.github.drumber.kitsune.data.presentation.model.media.unit.MediaUnit import kotlinx.parcelize.Parcelize @Parcelize data class MediaUnitDto( val id: String, val type: UnitType, val description: String?, val titles: Titles?, val canonicalTitle: String?, val number: Int?, val length: String?, val thumbnail: ImageDto?, // Episode specific attributes val seasonNumber: Int?, val relativeNumber: Int?, val airdate: String?, // Chapter specific attributes val volumeNumber: Int?, val published: String? ) : Parcelable { enum class UnitType { Episode, Chapter } } fun MediaUnit.toMediaUnitDto() = when (this) { is Episode -> toMediaUnitDto() is Chapter -> toMediaUnitDto() } fun MediaUnitDto.toMediaUnit() = when (type) { UnitType.Episode -> toEpisode() UnitType.Chapter -> toChapter() } private fun Episode.toMediaUnitDto() = MediaUnitDto( id = id, type = UnitType.Episode, description = description, titles = titles, canonicalTitle = canonicalTitle, number = number, length = length, thumbnail = thumbnail?.toImageDto(), seasonNumber = seasonNumber, relativeNumber = relativeNumber, airdate = airdate, volumeNumber = null, published = null ) private fun Chapter.toMediaUnitDto() = MediaUnitDto( id = id, type = UnitType.Chapter, description = description, titles = titles, canonicalTitle = canonicalTitle, number = number, length = length, thumbnail = thumbnail?.toImageDto(), seasonNumber = null, relativeNumber = null, airdate = null, volumeNumber = volumeNumber, published = published ) private fun MediaUnitDto.toEpisode() = Episode( id = id, description = description, titles = titles, canonicalTitle = canonicalTitle, number = number, length = length, thumbnail = thumbnail?.toImage(), seasonNumber = seasonNumber, relativeNumber = relativeNumber, airdate = airdate ) private fun MediaUnitDto.toChapter() = Chapter( id = id, description = description, titles = titles, canonicalTitle = canonicalTitle, number = number, length = length, thumbnail = thumbnail?.toImage(), volumeNumber = volumeNumber, published = published ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/algolia/AlgoliaKey.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.algolia data class AlgoliaKey( val key: String, val index: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/algolia/AlgoliaKeyCollection.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.algolia data class AlgoliaKeyCollection( val users: AlgoliaKey?, val posts: AlgoliaKey?, val media: AlgoliaKey?, val groups: AlgoliaKey?, val characters: AlgoliaKey? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/algolia/SearchType.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.algolia enum class SearchType(private val algoliaKey: (AlgoliaKeyCollection) -> AlgoliaKey?) { Users({ it.users }), Posts({ it.posts }), Media({ it.media }), Groups({ it.groups }), Characters({ it.characters }); fun getAlgoliaKey(algoliaKeyCollection: AlgoliaKeyCollection) = algoliaKey(algoliaKeyCollection) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/appupdate/AppRelease.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.appupdate data class AppRelease( val version: String, val url: String ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/appupdate/UpdateCheckResult.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.appupdate sealed class UpdateCheckResult { data object NoNewVersion : UpdateCheckResult() data class NewVersion(val release: AppRelease) : UpdateCheckResult() data class Error(val exception: Exception) : UpdateCheckResult() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/character/Character.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.character import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.presentation.model.user.FavoriteItem data class Character( val id: String, val slug: String?, val name: String?, val names: Titles?, val otherNames: List?, val malId: Int?, val description: String?, val image: Image?, val mediaCharacters: List? ) : FavoriteItem ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/character/CharacterSearchResult.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.character import io.github.drumber.kitsune.data.common.Image data class CharacterSearchResult( val id: String, val slug: String?, val name: String?, val image: Image?, val primaryMediaTitle: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/character/MediaCharacter.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.character import io.github.drumber.kitsune.data.presentation.model.media.Media data class MediaCharacter( val id: String, val role: MediaCharacterRole?, val media: Media? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/character/MediaCharacterRole.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.character import androidx.annotation.StringRes import io.github.drumber.kitsune.R enum class MediaCharacterRole { MAIN, SUPPORTING, RECURRING, CAMEO } @StringRes fun MediaCharacterRole.getStringRes() = when (this) { MediaCharacterRole.MAIN -> R.string.character_role_main MediaCharacterRole.SUPPORTING -> R.string.character_role_supporting MediaCharacterRole.RECURRING -> R.string.character_role_recurring MediaCharacterRole.CAMEO -> R.string.character_role_cameo } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/LibraryEntry.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library import io.github.drumber.kitsune.data.presentation.model.media.Media data class LibraryEntry( val id: String, val updatedAt: String?, val startedAt: String?, val finishedAt: String?, val progressedAt: String?, val status: LibraryStatus?, val progress: Int?, val reconsuming: Boolean?, val reconsumeCount: Int?, val volumesOwned: Int?, val ratingTwenty: Int?, val notes: String?, val privateEntry: Boolean?, val reactionSkipped: ReactionSkip?, val media: Media? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/LibraryEntryFilter.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.library.LibraryEntryKind data class LibraryEntryFilter( val kind: LibraryEntryKind, val libraryStatus: List, private val initialFilter: Filter = Filter() ) { fun pageSize(pageSize: Int): LibraryEntryFilter { return copy(initialFilter = Filter(initialFilter.options.toMutableMap()).pageLimit(pageSize)) } fun buildFilter() = Filter(initialFilter.options.toMutableMap()).apply { if (kind != LibraryEntryKind.All) { filter("kind", kind.name.lowercase()) } if (libraryStatus.isNotEmpty()) { val status = libraryStatus.joinToString(",") { it.getFilterValue() } filter("status", status) } } fun isFiltered() = kind != LibraryEntryKind.All || libraryStatus.isNotEmpty() /** Checks if the initial filter has a 'title' filter applied. */ fun isFilteredBySearchQuery() = initialFilter.hasFilterAttribute("title") } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/LibraryEntryModification.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library data class LibraryEntryModification( val id: String, val createTime: Long = System.currentTimeMillis(), val state: LibraryModificationState = LibraryModificationState.NOT_SYNCHRONIZED, val startedAt: String?, val finishedAt: String?, val status: LibraryStatus?, val progress: Int?, val reconsumeCount: Int?, val volumesOwned: Int?, /** Set to `-1` to remove rating (will be mapped to `null` by the json serializer) */ val ratingTwenty: Int?, val notes: String?, val privateEntry: Boolean? ) { companion object { fun withIdAndNulls(id: String) = LibraryEntryModification( id = id, startedAt = null, finishedAt = null, status = null, progress = null, reconsumeCount = null, volumesOwned = null, ratingTwenty = null, notes = null, privateEntry = null ) } /** * Apply modifications to the specified library entry. * * @param ignoreBlankNotes * Do not apply notes if modified notes is blank and library entry notes is null. * * @return the given library entry for convenience. */ fun applyToLibraryEntry( libraryEntry: LibraryEntry, ignoreBlankNotes: Boolean = true ): LibraryEntry { return libraryEntry.copy( startedAt = startedAt ?: libraryEntry.startedAt, finishedAt = finishedAt ?: libraryEntry.finishedAt, status = status ?: libraryEntry.status, progress = progress ?: libraryEntry.progress, reconsumeCount = reconsumeCount ?: libraryEntry.reconsumeCount, volumesOwned = volumesOwned ?: libraryEntry.volumesOwned, ratingTwenty = ratingTwenty ?: libraryEntry.ratingTwenty, notes = notes ?.takeIf { !ignoreBlankNotes || libraryEntry.notes != null || it.isNotBlank() } ?: libraryEntry.notes, privateEntry = privateEntry ?: libraryEntry.privateEntry ) } fun mergeModificationFrom(other: LibraryEntryModification) = LibraryEntryModification( id = id, status = other.status ?: status, progress = other.progress ?: progress, volumesOwned = other.volumesOwned ?: volumesOwned, reconsumeCount = other.reconsumeCount ?: reconsumeCount, notes = other.notes ?: notes, privateEntry = other.privateEntry ?: privateEntry, startedAt = other.startedAt ?: startedAt, finishedAt = other.finishedAt ?: finishedAt, ratingTwenty = other.ratingTwenty ?: ratingTwenty ) fun toLocalLibraryEntry(ignoreBlankNotes: Boolean = true): LibraryEntry { return LibraryEntry( id = id, updatedAt = null, startedAt = startedAt, finishedAt = finishedAt, progressedAt = null, status = status, progress = progress, reconsuming = null, reconsumeCount = reconsumeCount, volumesOwned = volumesOwned, ratingTwenty = ratingTwenty, notes = notes?.takeIf { !ignoreBlankNotes || it.isNotBlank() }, privateEntry = privateEntry, reactionSkipped = null, media = null ) } fun isEqualToLibraryEntry(libraryEntry: LibraryEntry): Boolean { return libraryEntry == applyToLibraryEntry(libraryEntry) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/LibraryEntryUiModel.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library sealed class LibraryEntryUiModel { data class StatusSeparatorModel( val status: LibraryStatus, val isMangaSelected: Boolean ) : LibraryEntryUiModel() data class EntryModel(val entry: LibraryEntryWithModification) : LibraryEntryUiModel() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/LibraryEntryWithModification.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library import io.github.drumber.kitsune.util.rating.RatingSystemUtil.formatRatingTwenty data class LibraryEntryWithModification( val libraryEntry: LibraryEntry, val modification: LibraryEntryModification? ) { val id get() = libraryEntry.id val media get() = libraryEntry.media val episodeCount: Int? get() = media?.episodeOrChapterCount val hasEpisodesCount: Boolean get() = episodeCount != null val episodeCountFormatted: String get() = episodeCount?.toString() ?: "∞" val progress get() = modification?.progress ?: libraryEntry.progress val hasStartedWatching: Boolean get() = progress?.equals(0) == false val hasStartedWatchingOrIsCurrent: Boolean get() = hasStartedWatching || status == LibraryStatus.Current val canWatchEpisode: Boolean get() = progress != episodeCount val volumesOwned get() = modification?.volumesOwned ?: libraryEntry.volumesOwned val ratingTwenty get() = modification?.ratingTwenty ?: libraryEntry.ratingTwenty val ratingFormatted: String? get() = ratingTwenty?.let { when { it == -1 -> null else -> it.formatRatingTwenty() } } val hasRating: Boolean get() = ratingTwenty != null val status get() = modification?.status ?: libraryEntry.status val reconsumeCount get() = modification?.reconsumeCount ?: libraryEntry.reconsumeCount val isPrivate get() = modification?.privateEntry ?: libraryEntry.privateEntry val startedAt get() = modification?.startedAt ?: libraryEntry.startedAt val finishedAt get() = modification?.finishedAt ?: libraryEntry.finishedAt val notes get() = modification?.notes ?: libraryEntry.notes val isSynchronizing get() = modification?.state == LibraryModificationState.SYNCHRONIZING val isNotSynced get() = !isSynchronizing && modification != null && !modification.isEqualToLibraryEntry(libraryEntry) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/LibraryModificationState.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library enum class LibraryModificationState { SYNCHRONIZING, NOT_SYNCHRONIZED } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/LibraryStatus.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library import io.github.drumber.kitsune.R enum class LibraryStatus { Current, Planned, Completed, OnHold, Dropped } fun LibraryStatus.getStringResId(isAnime: Boolean = true) = when (this) { LibraryStatus.Completed -> R.string.library_status_completed LibraryStatus.Current -> if (isAnime) R.string.library_status_watching else R.string.library_status_reading LibraryStatus.Dropped -> R.string.library_status_dropped LibraryStatus.OnHold -> R.string.library_status_on_hold LibraryStatus.Planned -> if (isAnime) R.string.library_status_planned else R.string.library_status_planned_manga } fun LibraryStatus.getFilterValue() = when (this) { LibraryStatus.Current -> "current" LibraryStatus.Planned -> "planned" LibraryStatus.Completed -> "completed" LibraryStatus.OnHold -> "on_hold" LibraryStatus.Dropped -> "dropped" } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/library/ReactionSkip.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.library enum class ReactionSkip { Unskipped, Skipped, Ignored } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/mapping/Mapping.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.mapping import io.github.drumber.kitsune.constants.Kitsu data class Mapping( val id: String, val externalSite: String?, val externalId: String? ) fun Mapping.getSiteName() = when (externalSite) { "kitsu/anime", "kitsu/manga" -> "Kitsu" "anidb" -> "AniDB" "anilist", "anilist/anime", "anilist/manga" -> "AniList" "myanimelist", "myanimelist/anime", "myanimelist/manga" -> "MyAnimeList" "thetvdb", "thetvdb/season", "thetvdb/series" -> "TheTVDB" "trakt" -> "Trakt" "hulu" -> "Hulu" "mangaupdates" -> "Baka-Updates Manga" else -> null } fun Mapping.getExternalUrl() = when (externalSite) { "kitsu/anime" -> "${Kitsu.BASE_URL}/anime/$externalId" "kitsu/manga" -> "${Kitsu.BASE_URL}/manga/$externalId" "anidb" -> "https://anidb.net/anime/$externalId" "anilist/anime" -> "https://anilist.co/anime/$externalId" "anilist/manga" -> "https://anilist.co/manga/$externalId" "myanimelist/anime" -> "https://myanimelist.net/anime/$externalId" "myanimelist/manga" -> "https://myanimelist.net/manga/$externalId" "thetvdb/season" -> "https://thetvdb.com/dereferrer/season/$externalId" "thetvdb/series" -> "https://thetvdb.com/dereferrer/series/$externalId" "thetvdb" -> "https://thetvdb.com/dereferrer/series/${externalId?.replace(Regex("/.*"), "")}" "trakt" -> "https://trakt.tv/shows/$externalId" "hulu" -> "https://hulu.jp/series/$externalId" "mangaupdates" -> "https://www.mangaupdates.com/series/$externalId" else -> null } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/Anime.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.common.media.AnimeSubtype import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.common.media.RatingFrequencies import io.github.drumber.kitsune.data.common.media.ReleaseStatus import io.github.drumber.kitsune.data.presentation.model.media.category.Category import io.github.drumber.kitsune.data.presentation.model.media.production.AnimeProduction import io.github.drumber.kitsune.data.presentation.model.media.relationship.MediaRelationship import io.github.drumber.kitsune.data.presentation.model.media.streamer.StreamingLink data class Anime( override val id: String, override val slug: String?, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val abbreviatedTitles: List?, override val averageRating: String?, override val ratingFrequencies: RatingFrequencies?, override val userCount: Int?, override val favoritesCount: Int?, override val popularityRank: Int?, override val ratingRank: Int?, override val startDate: String?, override val endDate: String?, override val nextRelease: String?, override val tba: String?, override val status: ReleaseStatus?, override val ageRating: AgeRating?, override val ageRatingGuide: String?, override val nsfw: Boolean?, override val posterImage: Image?, override val coverImage: Image?, override val totalLength: Int?, val episodeCount: Int?, val episodeLength: Int?, val youtubeVideoId: String?, val subtype: AnimeSubtype?, override val categories: List?, val animeProduction: List?, val streamingLinks: List?, override val mediaRelationships: List? ) : Media() { override val mediaType: MediaType get() = MediaType.Anime } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/Manga.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.common.media.MangaSubtype import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.common.media.RatingFrequencies import io.github.drumber.kitsune.data.common.media.ReleaseStatus import io.github.drumber.kitsune.data.presentation.model.media.category.Category import io.github.drumber.kitsune.data.presentation.model.media.relationship.MediaRelationship data class Manga( override val id: String, override val slug: String?, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val abbreviatedTitles: List?, override val averageRating: String?, override val ratingFrequencies: RatingFrequencies?, override val userCount: Int?, override val favoritesCount: Int?, override val popularityRank: Int?, override val ratingRank: Int?, override val startDate: String?, override val endDate: String?, override val nextRelease: String?, override val tba: String?, override val status: ReleaseStatus?, override val ageRating: AgeRating?, override val ageRatingGuide: String?, override val nsfw: Boolean?, override val posterImage: Image?, override val coverImage: Image?, override val totalLength: Int?, val chapterCount: Int?, val volumeCount: Int?, val subtype: MangaSubtype?, val serialization: String?, override val categories: List?, override val mediaRelationships: List? ) : Media() { override val mediaType: MediaType get() = MediaType.Manga } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/Media.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media import android.content.Context import androidx.annotation.StringRes import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.en import io.github.drumber.kitsune.data.common.enJp import io.github.drumber.kitsune.data.common.jaJp import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.common.media.RatingFrequencies import io.github.drumber.kitsune.data.common.media.ReleaseStatus import io.github.drumber.kitsune.data.presentation.model.media.category.Category import io.github.drumber.kitsune.data.presentation.model.media.production.AnimeProductionRole import io.github.drumber.kitsune.data.presentation.model.media.relationship.MediaRelationship import io.github.drumber.kitsune.data.presentation.model.user.FavoriteItem import io.github.drumber.kitsune.util.DataUtil import io.github.drumber.kitsune.util.TimeUtil import io.github.drumber.kitsune.util.extensions.format import io.github.drumber.kitsune.util.formatDate import io.github.drumber.kitsune.util.parseDate import io.github.drumber.kitsune.util.toCalendar import java.util.Calendar sealed class Media : FavoriteItem { abstract val id: String abstract val slug: String? abstract val description: String? abstract val titles: Titles? abstract val canonicalTitle: String? abstract val abbreviatedTitles: List? abstract val averageRating: String? abstract val ratingFrequencies: RatingFrequencies? abstract val userCount: Int? abstract val favoritesCount: Int? abstract val popularityRank: Int? abstract val ratingRank: Int? abstract val startDate: String? abstract val endDate: String? abstract val nextRelease: String? abstract val tba: String? abstract val status: ReleaseStatus? abstract val ageRating: AgeRating? abstract val ageRatingGuide: String? abstract val nsfw: Boolean? abstract val posterImage: Image? abstract val coverImage: Image? abstract val totalLength: Int? abstract val categories: List? abstract val mediaRelationships: List? //********************************************************************************************// abstract val mediaType: MediaType val title get() = DataUtil.getTitle(titles, canonicalTitle) val titleEn get() = titles?.en val titleEnJp get() = titles?.enJp val titleJaJp get() = titles?.jaJp val abbreviatedTitlesFormatted get() = abbreviatedTitles?.joinToString(", ") val avgRatingFormatted get() = averageRating?.tryFormatDouble() val posterImageUrl get() = posterImage?.smallOrHigher() val coverImageUrl get() = coverImage?.originalOrDown() val subtypeFormatted get() = when (this) { is Anime -> subtype is Manga -> subtype }?.name.orEmpty().replaceFirstChar(Char::titlecase) val publishingYear: Int? get() = startDate?.takeIf { it.isNotBlank() } ?.parseDate()?.toCalendar() ?.get(Calendar.YEAR) fun publishingYearText(context: Context): String { val publishingYear = publishingYear return when { publishingYear != null -> publishingYear.toString() status == ReleaseStatus.TBA -> context.getString(R.string.status_tba) else -> "-" } } @get:StringRes val seasonStringRes: Int get() { val date = startDate?.parseDate()?.toCalendar() return when (date?.get(Calendar.MONTH)?.plus(1)) { 12, 1, 2 -> R.string.season_winter in 3..5 -> R.string.season_spring in 6..8 -> R.string.season_summer in 9..11 -> R.string.season_fall else -> R.string.no_information } } val seasonYear: String get() = startDate?.parseDate()?.toCalendar()?.let { date -> val year = date.get(Calendar.YEAR) val month = date.get(Calendar.MONTH) + 1 if (month == 12) { year + 1 } else { year } }?.toString() ?: "" val airedText: String get() { var airedText = formatDate(startDate) if (!endDate.isNullOrBlank() && startDate != endDate) { airedText += " - ${formatDate(endDate)}" } return airedText } @get:StringRes val statusStringRes: Int get() = when (status) { ReleaseStatus.Current -> if (this is Anime) R.string.status_current else R.string.status_current_manga ReleaseStatus.Finished -> R.string.status_finished ReleaseStatus.TBA -> R.string.status_tba ReleaseStatus.Unreleased -> R.string.status_unreleased ReleaseStatus.Upcoming -> R.string.status_upcoming null -> R.string.no_information } val ageRatingText: String? get() { var ageRatingText = ageRating?.name ?: return null if (ageRatingGuide != null) { ageRatingText += " - $ageRatingGuide" } return ageRatingText } val serializationText: String? get() = (this as? Manga)?.serialization val chapters: String? get() = (this as? Manga)?.chapterCount?.toString() val volumes: String? get() = (this as? Manga)?.volumeCount?.let { volumes -> if (volumes > 0) { volumes.toString() } else { null } } val episodes: String? get() = (this as? Anime)?.episodeCount?.toString() val episodeOrChapterCount get() = (this as? Anime)?.episodeCount ?: (this as? Manga)?.chapterCount fun lengthText(context: Context): String? { if (this is Anime) { val count = episodeCount val length = episodeLength ?: return null val lengthEachText = context.getString(R.string.data_length_each, length) return if (count == null) { lengthEachText } else { val minutes = count * length.toLong() val durationText = TimeUtil.timeToHumanReadableFormat(minutes * 60, context) if (count > 1) { context.getString( R.string.data_length_total, durationText ) + " ($lengthEachText)" } else { durationText } } } return null } val trailerUrl: String? get() = if (this is Anime && !youtubeVideoId.isNullOrBlank()) { "https://www.youtube.com/watch?v=$youtubeVideoId" } else null val trailerCoverUrl: String? get() = if (this is Anime && !youtubeVideoId.isNullOrBlank()) { "https://img.youtube.com/vi/$youtubeVideoId/mqdefault.jpg" } else null fun getProducer(role: AnimeProductionRole): String? { return (this as? Anime)?.animeProduction?.filter { it.role == role } ?.mapNotNull { it.producer?.name } ?.distinct() ?.joinToString(", ") } fun hasStreamingLinks() = this is Anime && !streamingLinks.isNullOrEmpty() fun hasMediaRelationships() = !mediaRelationships.isNullOrEmpty() fun hasRatingFrequencies() = ratingFrequencies != null private fun formatDate(dateString: String?): String { return dateString?.takeIf { it.isNotBlank() }?.parseDate()?.formatDate() ?: "" } private fun String?.tryFormatDouble() = this?.toDoubleOrNull()?.format() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/MediaSelector.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media import android.os.Parcelable import io.github.drumber.kitsune.data.common.FilterOptions import io.github.drumber.kitsune.data.common.media.MediaType import kotlinx.parcelize.Parcelize @Parcelize data class MediaSelector( val mediaType: MediaType, val filterOptions: FilterOptions, val requestType: RequestType = RequestType.ALL ) : Parcelable val MediaType.identifier get() = when (this) { MediaType.Anime -> "anime" MediaType.Manga -> "manga" } enum class RequestType { ALL, TRENDING } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/category/Category.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.category data class Category( val id: String, val slug: String?, val title: String?, val description: String?, val nsfw: Boolean?, val totalMediaCount: Int?, val childCount: Int? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/category/CategoryNode.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.category data class CategoryNode( val category: Category, val childCategories: MutableList = mutableListOf() ) { fun hasChildren() = category.childCount?.equals(0) == false } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/production/AnimeProduction.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.production data class AnimeProduction( val id: String, val role: AnimeProductionRole?, val producer: Producer? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/production/AnimeProductionRole.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.production enum class AnimeProductionRole { Licensor, Producer, Studio } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/production/Casting.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.production import io.github.drumber.kitsune.data.presentation.model.character.Character data class Casting( val id: String, val role: String?, val voiceActor: Boolean?, val featured: Boolean?, val language: String?, val character: Character?, val person: Person? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/production/Person.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.production import io.github.drumber.kitsune.data.common.Image data class Person( val id: String, val name: String?, val description: String?, val image: Image? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/production/Producer.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.production data class Producer( val id: String, val slug: String?, val name: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/relationship/MediaRelationship.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.relationship import io.github.drumber.kitsune.data.presentation.model.media.Media data class MediaRelationship( val id: String, val role: MediaRelationshipRole?, val media: Media? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/relationship/MediaRelationshipRole.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.relationship import androidx.annotation.StringRes import io.github.drumber.kitsune.R /** * Media relationship roles, sort order from * https://github.com/hummingbird-me/kitsu-server/blob/the-future/app/models/media_relationship.rb */ enum class MediaRelationshipRole { Sequel, Prequel, AlternativeSetting, AlternativeVersion, SideStory, ParentStory, Summary, FullStory, Spinoff, Adaptation, Character, Other } @StringRes fun MediaRelationshipRole.getStringRes(): Int { return when (this) { MediaRelationshipRole.Sequel -> R.string.relationship_sequel MediaRelationshipRole.Prequel -> R.string.relationship_prequel MediaRelationshipRole.AlternativeSetting -> R.string.relationship_alternative_setting MediaRelationshipRole.AlternativeVersion -> R.string.relationship_alternative_version MediaRelationshipRole.SideStory -> R.string.relationship_side_story MediaRelationshipRole.ParentStory -> R.string.relationship_parent_story MediaRelationshipRole.Summary -> R.string.relationship_summary MediaRelationshipRole.FullStory -> R.string.relationship_full_story MediaRelationshipRole.Spinoff -> R.string.relationship_spinoff MediaRelationshipRole.Adaptation -> R.string.relationship_adaptation MediaRelationshipRole.Character -> R.string.relationship_character MediaRelationshipRole.Other -> R.string.relationship_other } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/streamer/Streamer.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.streamer data class Streamer( val id: String, val siteName: String?, ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/streamer/StreamingLink.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.streamer data class StreamingLink( val id: String, val url: String?, val subs: List?, val dubs: List?, val streamer: Streamer? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/unit/Chapter.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.unit import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles data class Chapter( override val id: String, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val number: Int?, val volumeNumber: Int?, override val length: String?, override val thumbnail: Image?, val published: String? ) : MediaUnit { override val numberStringRes get() = R.string.unit_chapter override val lengthStringRes: Int get() = R.plurals.unit_pages override val date: String? get() = published } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/unit/Episode.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.unit import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles data class Episode( override val id: String, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val number: Int?, val seasonNumber: Int?, val relativeNumber: Int?, override val length: String?, val airdate: String?, override val thumbnail: Image? ) : MediaUnit { override val numberStringRes get() = R.string.unit_episode override val lengthStringRes: Int get() = R.plurals.duration_minutes override val date: String? get() = airdate } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/media/unit/MediaUnit.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.media.unit import android.content.Context import androidx.annotation.PluralsRes import androidx.annotation.StringRes import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.util.DataUtil import io.github.drumber.kitsune.util.formatDate import io.github.drumber.kitsune.util.parseDate import java.text.SimpleDateFormat sealed interface MediaUnit { val id: String? val description: String? val titles: Titles? val canonicalTitle: String? val number: Int? val length: String? val thumbnail: Image? //********************************************************************************************// @get:StringRes val numberStringRes: Int @get:PluralsRes val lengthStringRes: Int val date: String? fun hasValidTitle(): Boolean { return DataUtil.getTitle(titles, canonicalTitle) != null && !Regex("(Chapter|Episode)\\s*\\d+").matches(canonicalTitle ?: "") } fun title(context: Context) = if (hasValidTitle()) { DataUtil.getTitle(titles, canonicalTitle) } else { numberText(context) } fun numberText(context: Context): String? { return number?.let { context.getString(numberStringRes, it) } } fun formatDate(): String? { return date?.parseDate()?.formatDate(SimpleDateFormat.SHORT) } fun length(context: Context): String? { return length?.let { context.resources.getQuantityString(lengthStringRes, it.toInt(), it) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/Favorite.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user data class Favorite( val id: String, val favRank: Int?, val item: FavoriteItem?, val user: User? ) interface FavoriteItem ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/User.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLink import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStats data class User( val id: String, val createdAt: String?, val name: String?, val slug: String?, val title: String?, val avatar: Image?, val coverImage: Image?, val about: String?, val location: String?, val gender: String?, val birthday: String?, val waifuOrHusbando: String?, val followersCount: Int?, val followingCount: Int?, val country: String?, val language: String?, val timeZone: String?, val stats: List?, val favorites: List?, val waifu: Character?, val profileLinks: List? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/profilelinks/ProfileLink.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user.profilelinks import io.github.drumber.kitsune.data.presentation.model.user.User data class ProfileLink( val id: String, val url: String?, val profileLinkSite: ProfileLinkSite?, val user: User? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/profilelinks/ProfileLinkSite.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user.profilelinks import android.os.Parcelable import kotlinx.parcelize.Parcelize @Parcelize data class ProfileLinkSite( val id: String, val name: String? ) : Parcelable ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/stats/AmountConsumedPercentiles.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user.stats data class AmountConsumedPercentiles( val media: Float?, val units: Float?, val time: Float? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/stats/UserStats.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user.stats data class UserStats( val id: String, val kind: UserStatsKind?, val statsData: UserStatsData? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/stats/UserStatsData.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user.stats sealed class UserStatsData { data class CategoryBreakdownData( val total: Int?, val categories: Map? ) : UserStatsData() data class AmountConsumedData( val time: Long?, val media: Int?, val units: Int?, val completed: Int?, val percentiles: AmountConsumedPercentiles?, val averageDiffs: AmountConsumedPercentiles? ) : UserStatsData() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/presentation/model/user/stats/UserStatsKind.kt ================================================ package io.github.drumber.kitsune.data.presentation.model.user.stats enum class UserStatsKind { AnimeActivityHistory, AnimeAmountConsumed, AnimeCategoryBreakdown, AnimeFavoriteYear, MangaActivityHistory, MangaAmountConsumed, MangaCategoryBreakdown, MangaFavoriteYear } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/AccessTokenRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.mapper.AuthMapper.toLocalAccessToken import io.github.drumber.kitsune.data.source.local.auth.AccessTokenLocalDataSource import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken import io.github.drumber.kitsune.data.source.network.auth.AccessTokenNetworkDataSource import io.github.drumber.kitsune.data.source.network.auth.model.ObtainAccessToken import io.github.drumber.kitsune.data.source.network.auth.model.RefreshAccessToken import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.logI import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.Date class AccessTokenRepository( private val localAccessTokenDataSource: AccessTokenLocalDataSource, private val remoteAccessTokenDataSource: AccessTokenNetworkDataSource ) { private val mutex = Mutex() private var isAccessTokenLoaded = false private var cachedAccessToken: LocalAccessToken? = null private val _accessTokenState by lazy { MutableStateFlow( if (hasAccessToken()) AccessTokenState.PRESENT else AccessTokenState.NOT_PRESENT ) } val accessTokenState get() = _accessTokenState.asStateFlow() fun getAccessToken(): LocalAccessToken? { if (!isAccessTokenLoaded) { cachedAccessToken = localAccessTokenDataSource.loadAccessToken() isAccessTokenLoaded = true cachedAccessToken?.let { logI("Access token expires on: ${Date(it.getExpirationTimeInSeconds() * 1000)}") } } return cachedAccessToken } fun hasAccessToken(): Boolean { return getAccessToken() != null } suspend fun clearAccessToken() { mutex.withLock { cachedAccessToken = null localAccessTokenDataSource.clearAccessToken() _accessTokenState.update { AccessTokenState.NOT_PRESENT } } } suspend fun obtainAccessToken(username: String, password: String): LocalAccessToken { mutex.withLock { val accessToken = remoteAccessTokenDataSource.obtainAccessToken( ObtainAccessToken( username = username, password = password ) ).toLocalAccessToken() storeAccessToken(accessToken) _accessTokenState.update { AccessTokenState.PRESENT } return accessToken } } suspend fun refreshAccessToken(): LocalAccessToken { val refreshToken = getAccessToken()?.refreshToken ?: throw IllegalStateException("No refresh token available. Are you logged in?") mutex.withLock { // Check if the access token was changed by a concurrent request val localAccessToken = getAccessToken() if (localAccessToken != null && localAccessToken.refreshToken != refreshToken) { logD("Access token was updated by a concurrent request. Returning the updated token.") return localAccessToken } val accessToken = remoteAccessTokenDataSource.refreshToken( RefreshAccessToken( refreshToken = refreshToken ) ).toLocalAccessToken() storeAccessToken(accessToken) return accessToken } } private fun storeAccessToken(accessToken: LocalAccessToken) { localAccessTokenDataSource.storeAccessToken(accessToken) cachedAccessToken = accessToken } enum class AccessTokenState { NOT_PRESENT, PRESENT } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/AlgoliaKeyRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.mapper.AlgoliaMapper.toAlgoliaKeyCollection import io.github.drumber.kitsune.data.presentation.model.algolia.AlgoliaKeyCollection import io.github.drumber.kitsune.data.source.network.algolia.AlgoliaKeyNetworkDataSource class AlgoliaKeyRepository( private val remoteAlgoliaKeyDataSource: AlgoliaKeyNetworkDataSource ) { private var cache: AlgoliaKeyCollection? = null suspend fun getAllAlgoliaKeys(): AlgoliaKeyCollection { return cache ?: remoteAlgoliaKeyDataSource.getAllAlgoliaKeys().toAlgoliaKeyCollection().also { cache = it } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/AnimeRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.map import io.github.drumber.kitsune.constants.Repository import io.github.drumber.kitsune.data.mapper.MediaMapper.toAnime import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.source.network.media.AnimeNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.AnimePagingDataSource import io.github.drumber.kitsune.data.source.network.media.TrendingAnimePagingDataSource import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.flow.map class AnimeRepository( private val animeNetworkDataSource: AnimeNetworkDataSource ) { suspend fun getAllAnime(filter: Filter): List? { return animeNetworkDataSource.getAllAnime(filter).data?.map { it.toAnime() } } suspend fun getTrending(filter: Filter): List? { return animeNetworkDataSource.getTrending(filter).data?.map { it.toAnime() } } suspend fun getAnime(id: String, filter: Filter): Anime? { return animeNetworkDataSource.getAnime(id, filter)?.toAnime() } suspend fun getLanguages(id: String): List { return animeNetworkDataSource.getLanguages(id) } fun animePager(filter: Filter, pageSize: Int) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), pagingSourceFactory = { AnimePagingDataSource(animeNetworkDataSource, filter.pageLimit(pageSize)) } ).flow.map { pagingData -> pagingData.map { it.toAnime() } } fun trendingAnimePager(filter: Filter, pageSize: Int) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), pagingSourceFactory = { TrendingAnimePagingDataSource(animeNetworkDataSource, filter.pageLimit(pageSize)) } ).flow.map { pagingData -> pagingData.map { it.toAnime() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/AppUpdateRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.mapper.AppUpdateMapper.toAppRelease import io.github.drumber.kitsune.data.presentation.model.appupdate.UpdateCheckResult import io.github.drumber.kitsune.data.source.network.appupdate.AppReleaseNetworkDataSource import io.github.drumber.kitsune.util.logE class AppUpdateRepository( private val appReleaseDataSource: AppReleaseNetworkDataSource ) { suspend fun checkForUpdates(currentVersion: String): UpdateCheckResult { return try { val release = appReleaseDataSource.getLatestRelease().toAppRelease() if (isNewVersion(currentVersion, release.version)) { UpdateCheckResult.NewVersion(release) } else { UpdateCheckResult.NoNewVersion } } catch (e: Exception) { logE("Failed to fetch latest app release.", e) UpdateCheckResult.Error(e) } } private fun isNewVersion(localVersion: String, remoteVersion: String): Boolean { if (localVersion.trim() == remoteVersion.trim()) { return false } return isSemanticVersionHigher(localVersion, remoteVersion) } private fun isSemanticVersionHigher(localVersion: String, remoteVersion: String): Boolean { // replace everything that is not a digit at the beginning of the string val regex = "^\\D+".toRegex() // split the version strings into semantic version parts val localParts = localVersion.replace(regex, "").trim().split(".") val remoteParts = remoteVersion.replace(regex, "").trim().split(".") for (i in 0 until maxOf(localParts.size, remoteParts.size)) { val localPartString = localParts.getOrNull(i) ?: "0" val remotePartString = remoteParts.getOrNull(i) ?: "0" val localPartNumber = localPartString.toIntOrNull() val remotePartNumber = remotePartString.toIntOrNull() // Both parts are numbers: standard numeric comparison if (remotePartNumber != null && localPartNumber != null) { if (remotePartNumber != localPartNumber) { return remotePartNumber > localPartNumber } // both parts are equal, continue to next part continue } // Remote is a number and local is a string starting with same number: // => remote is stable version and has higher precedence (e.g. "0" vs "0-beta1") if (remotePartNumber != null && localPartString.startsWith(remotePartString)) { if (localPartString.endsWith("-debug")) { // do not report debug versions as new versions return false } return true } // Local is a number and remote is a string starting with same number: // => local is stable version and has higher precedence if (localPartNumber != null && remotePartString.startsWith(localPartString)) { return false } // Both parts are non-numeric: fallback to string comparison (e.g. "0-beta1" vs "0-beta2") if (remotePartString != localPartString) { return remotePartString > localPartString } } return false } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/CastingRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.map import io.github.drumber.kitsune.constants.Repository import io.github.drumber.kitsune.data.mapper.MediaMapper.toCasting import io.github.drumber.kitsune.data.source.network.media.CastingNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.CastingPagingDataSource import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.flow.map class CastingRepository( private val castingNetworkDataSource: CastingNetworkDataSource ) { fun castingPager(filter: Filter, pageSize: Int) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), pagingSourceFactory = { CastingPagingDataSource(castingNetworkDataSource, filter.pageLimit(pageSize)) } ).flow.map { pagingData -> pagingData.map { it.toCasting() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/CategoryRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.mapper.MediaMapper.toCategory import io.github.drumber.kitsune.data.presentation.model.media.category.Category import io.github.drumber.kitsune.data.source.network.media.CategoryNetworkDataSource import io.github.drumber.kitsune.data.common.Filter class CategoryRepository( private val categoryNetworkDataSource: CategoryNetworkDataSource ) { suspend fun getAllCategories(filter: Filter): List? { return categoryNetworkDataSource.getAllCategories(filter)?.map { it.toCategory() } } suspend fun getCategory(id: String, filter: Filter): Category? { return categoryNetworkDataSource.getCategory(id, filter)?.toCategory() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/CharacterRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.mapper.CharacterMapper.toCharacter import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.source.network.character.CharacterNetworkDataSource import io.github.drumber.kitsune.data.common.Filter class CharacterRepository( private val characterNetworkDataSource: CharacterNetworkDataSource ) { suspend fun getCharacter(id: String, filter: Filter): Character? { return characterNetworkDataSource.getCharacter(id, filter)?.toCharacter() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/FavoriteRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.mapper.UserMapper.toFavorite import io.github.drumber.kitsune.data.presentation.model.user.Favorite import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import io.github.drumber.kitsune.data.source.network.user.FavoriteNetworkDataSource import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavorite import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavoriteItem import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser import io.github.drumber.kitsune.data.common.Filter class FavoriteRepository( private val remoteFavoriteDataSource: FavoriteNetworkDataSource ) { suspend fun getAllFavorites(filter: Filter): List? { return remoteFavoriteDataSource.getAllFavorites(filter)?.map { it.toFavorite() } } suspend fun createMediaFavorite(userId: String, mediaType: MediaType, mediaId: String): Favorite? { val favoriteItem = when (mediaType) { MediaType.Anime -> NetworkAnime.empty(mediaId) MediaType.Manga -> NetworkManga.empty(mediaId) } return createFavorite(userId, favoriteItem) } suspend fun createCharacterFavorite(userId: String, characterId: String): Favorite? { val favoriteItem = NetworkCharacter(id = characterId) return createFavorite(userId,favoriteItem) } private suspend fun createFavorite(userId: String, item: NetworkFavoriteItem): Favorite? { val newFavorite = NetworkFavorite( item = item, user = NetworkUser(id = userId) ) return remoteFavoriteDataSource.createFavorite(newFavorite)?.toFavorite() } suspend fun deleteFavorite(id: String): Boolean { return remoteFavoriteDataSource.deleteFavorite(id) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/LibraryChangeListener.kt ================================================ package io.github.drumber.kitsune.data.repository import android.content.Context import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.domain.work.UpdateLibraryWidgetUseCase interface LibraryChangeListener { fun onNewLibraryEntry(libraryEntry: LibraryEntry) fun onUpdateLibraryEntry( libraryEntryModification: LibraryEntryModification, updatedLibraryEntry: LibraryEntry? ) fun onRemoveLibraryEntry(id: String) fun onDataInsertion(libraryEntries: List) } class WidgetLibraryChangeListener( private val context: Context, private val updateLibraryWidget: UpdateLibraryWidgetUseCase ) : LibraryChangeListener { override fun onNewLibraryEntry(libraryEntry: LibraryEntry) { if (libraryEntry.status == LibraryStatus.Current) updateWidgets() } override fun onUpdateLibraryEntry( libraryEntryModification: LibraryEntryModification, updatedLibraryEntry: LibraryEntry? ) { updateWidgets() } override fun onRemoveLibraryEntry(id: String) { updateWidgets() } override fun onDataInsertion(libraryEntries: List) { if (libraryEntries.any { it.status == LibraryStatus.Current }) updateWidgets() } private fun updateWidgets() { updateLibraryWidget(context) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/LibraryEntryRemoteMediator.kt ================================================ package io.github.drumber.kitsune.data.repository import androidx.paging.ExperimentalPagingApi import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLocalLibraryEntry import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLocalLibraryStatus import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryFilter import io.github.drumber.kitsune.data.source.local.LocalDatabase import io.github.drumber.kitsune.data.source.local.library.LibraryLocalDataSource import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntry import io.github.drumber.kitsune.data.source.local.library.model.RemoteKeyEntity import io.github.drumber.kitsune.data.source.local.library.model.RemoteKeyType import io.github.drumber.kitsune.data.source.network.library.LibraryNetworkDataSource import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.parseUtcDate @OptIn(ExperimentalPagingApi::class) class LibraryEntryRemoteMediator( private val filter: LibraryEntryFilter, private val networkDataSource: LibraryNetworkDataSource, private val localDataSource: LibraryLocalDataSource ) : RemoteMediator() { /** * Implementation based on android paging example from * [Google Code Labs](https://github.com/googlecodelabs/android-paging/blob/78d231f6fbe9bf1326993362e1d08f823bef5ea2/app/src/main/java/com/example/android/codelabs/paging/data/GithubRemoteMediator.kt). */ override suspend fun load( loadType: LoadType, state: PagingState ): MediatorResult { return try { val pageOffset = when (loadType) { LoadType.REFRESH -> Kitsu.DEFAULT_PAGE_OFFSET LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) LoadType.APPEND -> { val remoteKeys = getRemoteKeyForLastItem(state) // If remoteKeys is null, that means the refresh result is not in the database yet. // We can return Success with `endOfPaginationReached = false` because Paging // will call this method again if RemoteKeys becomes non-null. // If remoteKeys is NOT NULL but its prevKey is null, that means we've reached // the end of pagination for append. var nextKey = remoteKeys?.nextPageKey ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null) state.pages.lastOrNull()?.prevKey?.let { prevPage -> // last page is equal or greater than reported next page: RemoteKey is out of sync if (prevPage >= nextKey) { localDataSource.deleteRemoteKeyByResourceId(remoteKeys.resourceId, remoteKeys.remoteKeyType) getRemoteKeyForLastItem(state)?.nextPageKey?.let { nextKey = it } ?: return MediatorResult.Success(endOfPaginationReached = false) } } nextKey } } val pageData = networkDataSource.getAllLibraryEntries( filter.buildFilter().pageOffset(pageOffset) ) val data = pageData.data?.map { it.toLocalLibraryEntry() } ?: throw NoDataException("Received data is 'null'.") localDataSource.runDatabaseTransaction { // only clear database on REFRESH if (loadType == LoadType.REFRESH) { logD("Clearing filtered library entries and corresponding remote keys from database.") clearLibraryEntriesForFilterIgnoringNewerLibraryEntries(filter, data) } data.forEach { libraryEntry -> localDataSource.insertLibraryEntryIfUpdatedAtIsNewer(libraryEntry) } val remoteKeys = data.map { RemoteKeyEntity(it.id, RemoteKeyType.LibraryEntry, pageData.prev, pageData.next) } remoteKeyDao().insertALl(remoteKeys) } MediatorResult.Success(endOfPaginationReached = pageData.next == null) } catch (e: Exception) { MediatorResult.Error(e) } } private suspend fun LocalDatabase.clearLibraryEntriesForFilterIgnoringNewerLibraryEntries( filter: LibraryEntryFilter, data: List ) { // clear all library entries in database with the selected kind and status val libraryEntriesToBeCleared = localDataSource.getLibraryEntriesByKindAndStatus( filter.kind, filter.libraryStatus.map { it.toLocalLibraryStatus() } ).filter { existingLibraryEntry -> // do not clear library entries from database that are newer than the ones received val updatedAtOfExistingEntry = existingLibraryEntry.updatedAt val updatedAtOfNewEntry = data.find { it.id == existingLibraryEntry.id }?.updatedAt if (updatedAtOfExistingEntry.isNullOrBlank() || updatedAtOfNewEntry.isNullOrBlank()) return@filter true val existingEntryUpdateTime = updatedAtOfExistingEntry.parseUtcDate()?.time val newEntryUpdateTime = updatedAtOfNewEntry.parseUtcDate()?.time return@filter existingEntryUpdateTime == null || newEntryUpdateTime == null || existingEntryUpdateTime <= newEntryUpdateTime } libraryEntryDao().deleteAll(libraryEntriesToBeCleared) val remoteKeyIdsToClear = libraryEntriesToBeCleared.map { it.id } remoteKeyDao().deleteAllByResourceId(remoteKeyIdsToClear, RemoteKeyType.LibraryEntry) } private suspend fun getRemoteKeyForLastItem(state: PagingState): RemoteKeyEntity? { // Get the last page that was retrieved, that contained items. // From that last page, get the last item that has a valid remote key. return state.pages.lastOrNull { it.data.isNotEmpty() }?.data ?.lastOrNull { libraryEntry -> localDataSource.getRemoteKeyByResourceId( libraryEntry.id, RemoteKeyType.LibraryEntry ) != null } ?.let { libraryEntry -> // Get the remote keys of the last item retrieved localDataSource.getRemoteKeyByResourceId( libraryEntry.id, RemoteKeyType.LibraryEntry ) } } override suspend fun initialize(): InitializeAction { return InitializeAction.LAUNCH_INITIAL_REFRESH } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/LibraryRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import androidx.lifecycle.LiveData import androidx.lifecycle.map import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.map import io.github.drumber.kitsune.constants.Repository import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.common.exception.NotFoundException import io.github.drumber.kitsune.data.common.library.LibraryEntryKind import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLibraryEntry import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLibraryEntryModification import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLocalLibraryEntry import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLocalLibraryEntryModification import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLocalLibraryModificationState import io.github.drumber.kitsune.data.mapper.LibraryMapper.toLocalLibraryStatus import io.github.drumber.kitsune.data.mapper.LibraryMapper.toNetworkLibraryStatus import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryFilter import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryModificationState import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.source.local.library.LibraryLocalDataSource import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntry import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntryModification import io.github.drumber.kitsune.data.source.network.library.LibraryEntryPagingDataSource import io.github.drumber.kitsune.data.source.network.library.LibraryNetworkDataSource import io.github.drumber.kitsune.data.source.network.library.model.NetworkLibraryEntry import io.github.drumber.kitsune.data.utils.InvalidatingPagingSourceFactory import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.parseUtcDate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import retrofit2.HttpException class LibraryRepository( private val remoteLibraryDataSource: LibraryNetworkDataSource, private val localLibraryDataSource: LibraryLocalDataSource, private val libraryChangeListener: LibraryChangeListener, private val coroutineScope: CoroutineScope ) { private val filterForFullLibraryEntry get() = Filter().include("anime", "manga") suspend fun addNewLibraryEntry( userId: String, media: Media, status: LibraryStatus ): LibraryEntry? { val newLibraryEntry = NetworkLibraryEntry.new( userId, media.mediaType, media.id, status.toNetworkLibraryStatus() ) return coroutineScope.async { val libraryEntry = remoteLibraryDataSource.postLibraryEntry( newLibraryEntry, filterForFullLibraryEntry ) if (libraryEntry != null) { localLibraryDataSource.insertLibraryEntry(libraryEntry.toLocalLibraryEntry()) } libraryEntry?.toLibraryEntry() }.await().also { libraryEntry -> libraryEntry?.let { libraryChangeListener.onNewLibraryEntry(it) } } } suspend fun removeLibraryEntry(libraryEntryId: String) { remoteLibraryDataSource.deleteLibraryEntry(libraryEntryId) localLibraryDataSource.deleteLibraryEntryAndAnyModification(libraryEntryId) libraryChangeListener.onRemoveLibraryEntry(libraryEntryId) } /** * Check if library entry was deleted on the server. If so, remove it from local database. */ suspend fun mayRemoveLibraryEntryLocally(libraryEntryId: String) { if (!doesLibraryEntryExist(libraryEntryId)) { localLibraryDataSource.deleteLibraryEntryAndAnyModification(libraryEntryId) libraryChangeListener.onRemoveLibraryEntry(libraryEntryId) } } private suspend fun doesLibraryEntryExist(libraryEntryId: String): Boolean { return try { remoteLibraryDataSource.getLibraryEntry( libraryEntryId, Filter().fields("libraryEntries", "id") ) != null } catch (e: HttpException) { if (e.code() == 404) return false throw e } } suspend fun updateLibraryEntry( libraryEntryModification: LibraryEntryModification ): LibraryEntry { val modification = libraryEntryModification.copy(state = LibraryModificationState.SYNCHRONIZING) var libraryEntry: LibraryEntry? = null return try { coroutineScope.async { localLibraryDataSource.insertLibraryEntryModification(modification.toLocalLibraryEntryModification()) }.await() val networkLibraryEntry = pushModificationToService(modification) coroutineScope.async { if (isLibraryEntryNotOlderThanInDatabase(networkLibraryEntry.toLocalLibraryEntry())) { localLibraryDataSource.updateLibraryEntryAndDeleteModification( networkLibraryEntry.toLocalLibraryEntry(), modification.toLocalLibraryEntryModification() ) } }.await() networkLibraryEntry.toLibraryEntry().also { libraryEntry = it } } catch (e: NotFoundException) { localLibraryDataSource.deleteLibraryEntryAndAnyModification(modification.id) throw e } catch (e: Exception) { insertLocalModificationOrDeleteIfSameAsLibraryEntry( modification.copy(state = LibraryModificationState.NOT_SYNCHRONIZED) .toLocalLibraryEntryModification() ) throw e } finally { libraryChangeListener.onUpdateLibraryEntry(libraryEntryModification, libraryEntry) } } suspend fun fetchAndStoreLibraryEntryForMedia(userId: String, media: Media): LibraryEntry? { val requestFilter = filterForFullLibraryEntry.copy() .filter("user_id", userId) when (media) { is Anime -> requestFilter.filter("anime_id", media.id) is Manga -> requestFilter.filter("manga_id", media.id) } val filter = LibraryEntryFilter( kind = LibraryEntryKind.All, libraryStatus = emptyList(), initialFilter = requestFilter ) return fetchAndStoreLibraryEntriesForFilter(filter)?.firstOrNull() } suspend fun fetchAndStoreLibraryEntriesForFilter(filter: LibraryEntryFilter): List? { val libraryEntries = remoteLibraryDataSource.getAllLibraryEntries(filter.buildFilter()).data if (libraryEntries != null) { localLibraryDataSource.insertAllLibraryEntries(libraryEntries.map { it.toLocalLibraryEntry() }) } return libraryEntries?.map { it.toLibraryEntry() }?.also { libraryChangeListener.onDataInsertion(it) } } suspend fun fetchAllLibraryEntries(filter: Filter): List? { return remoteLibraryDataSource.getAllLibraryEntries(filter).data?.map { it.toLibraryEntry() } } suspend fun fetchLibraryEntry(id: String, filter: Filter): LibraryEntry? { return remoteLibraryDataSource.getLibraryEntry(id, filter)?.toLibraryEntry() } suspend fun getLibraryEntryFromDatabase(id: String): LibraryEntry? { return localLibraryDataSource.getLibraryEntry(id)?.toLibraryEntry() } suspend fun getLibraryEntryFromMedia(mediaId: String): LibraryEntry? { return localLibraryDataSource.getLibraryEntryFromMedia(mediaId)?.toLibraryEntry() } suspend fun getLibraryEntriesWithModificationsByStatus(status: List): List { return localLibraryDataSource.getLibraryEntriesWithModificationsByStatus( status.map { it.toLocalLibraryStatus() } ).map { LibraryEntryWithModification( libraryEntry = it.libraryEntry.toLibraryEntry(), modification = it.libraryEntryModification?.toLibraryEntryModification() ) } } fun getLibraryEntriesWithModificationsByStatusAsFlow(status: List): Flow> { return localLibraryDataSource.getLibraryEntriesWithModificationsByStatusAsFlow( status.map { it.toLocalLibraryStatus() } ).map { entries -> entries.map { LibraryEntryWithModification( libraryEntry = it.libraryEntry.toLibraryEntry(), modification = it.libraryEntryModification?.toLibraryEntryModification() ) } } } fun getLibraryEntryWithModificationFromMediaAsLiveData(mediaId: String): LiveData { return localLibraryDataSource.getLibraryEntryWithModificationFromMediaAsLiveData(mediaId) .map { entry -> entry?.let { LibraryEntryWithModification( libraryEntry = it.libraryEntry.toLibraryEntry(), modification = it.libraryEntryModification?.toLibraryEntryModification() ) } } } //********************************************************************************************// // Library modifications related methods //********************************************************************************************// suspend fun getLibraryEntryModification(id: String): LibraryEntryModification? { return localLibraryDataSource.getLibraryEntryModification(id)?.toLibraryEntryModification() } suspend fun getAllLibraryEntryModifications(): List { return localLibraryDataSource.getAllLocalLibraryModifications() .map { it.toLibraryEntryModification() } } fun getLibraryEntryModificationsAsFlow(): Flow> { return localLibraryDataSource.getAllLibraryEntryModificationsAsFlow() .map { modifications -> modifications.map { it.toLibraryEntryModification() } } } fun getLibraryEntryModificationsByStateAsLiveData(state: LibraryModificationState): LiveData> { return localLibraryDataSource .getLibraryEntryModificationsByStateAsLiveData(state.toLocalLibraryModificationState()) .map { modifications -> modifications.map { it.toLibraryEntryModification() } } } private suspend fun pushModificationToService( modification: LibraryEntryModification ): NetworkLibraryEntry { val updatedLibraryEntry = NetworkLibraryEntry.update( id = modification.id, startedAt = modification.startedAt, finishedAt = modification.finishedAt, status = modification.status?.toNetworkLibraryStatus(), progress = modification.progress, reconsumeCount = modification.reconsumeCount, volumesOwned = modification.volumesOwned, ratingTwenty = modification.ratingTwenty, notes = modification.notes, isPrivate = modification.privateEntry, ) try { val libraryEntry = remoteLibraryDataSource.updateLibraryEntry( modification.id, updatedLibraryEntry, filterForFullLibraryEntry ) ?: throw NoDataException("Received library entry for ID '${modification.id}' is 'null'.") return libraryEntry } catch (e: HttpException) { if (e.code() == 404) { throw NotFoundException( "Library entry with ID '${modification.id}' does not exist.", e ) } throw e } } private suspend fun insertLocalModificationOrDeleteIfSameAsLibraryEntry( libraryEntryModification: LocalLibraryEntryModification ) { val modificationInDb = localLibraryDataSource.getLibraryEntryModification(libraryEntryModification.id) if (modificationInDb != null && modificationInDb.createTime > libraryEntryModification.createTime) { logD("Modification in database is newer than the one being inserted. Ignoring $libraryEntryModification") return } val libraryEntry = localLibraryDataSource.getLibraryEntry(libraryEntryModification.id) if (libraryEntry != null && libraryEntryModification.isEqualToLibraryEntry(libraryEntry)) { localLibraryDataSource.deleteLibraryEntryModification(libraryEntryModification) } else { localLibraryDataSource.insertLibraryEntryModification(libraryEntryModification) } } private suspend fun isLibraryEntryNotOlderThanInDatabase(libraryEntry: LocalLibraryEntry): Boolean { return localLibraryDataSource.getLibraryEntry(libraryEntry.id)?.let { dbEntry -> val dbUpdatedAt = dbEntry.updatedAt?.parseUtcDate() ?: return true val thisUpdatedAt = libraryEntry.updatedAt?.parseUtcDate() ?: return true thisUpdatedAt.time >= dbUpdatedAt.time } ?: true } //********************************************************************************************// // Paging related methods //********************************************************************************************// private val invalidatingPagingSourceFactory = InvalidatingPagingSourceFactory { filter -> localLibraryDataSource.getLibraryEntriesByKindAndStatusAsPagingSource( kind = filter.kind, status = filter.libraryStatus.map { it.toLocalLibraryStatus() } ) } @OptIn(ExperimentalPagingApi::class) fun libraryEntriesPager(pageSize: Int, filter: LibraryEntryFilter) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), remoteMediator = LibraryEntryRemoteMediator( filter.pageSize(pageSize), remoteLibraryDataSource, localLibraryDataSource ), pagingSourceFactory = { invalidatingPagingSourceFactory.createPagingSource(filter) } ).flow.map { pagingData -> pagingData.map { it.toLibraryEntry() } } fun searchLibraryEntriesPager(pageSize: Int, filter: Filter) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), pagingSourceFactory = { LibraryEntryPagingDataSource( remoteLibraryDataSource, filter.pageLimit(pageSize) ) } ).flow.map { pagingData -> pagingData.map { it.toLibraryEntry() } } fun invalidatePagingSources() { invalidatingPagingSourceFactory.invalidate() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/MangaRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.map import io.github.drumber.kitsune.constants.Repository import io.github.drumber.kitsune.data.mapper.MediaMapper.toManga import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.source.network.media.MangaNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.MangaPagingDataSource import io.github.drumber.kitsune.data.source.network.media.TrendingMangaPagingDataSource import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.flow.map class MangaRepository( private val mangaNetworkDataSource: MangaNetworkDataSource ) { suspend fun getAllManga(filter: Filter): List? { return mangaNetworkDataSource.getAllManga(filter).data?.map { it.toManga() } } suspend fun getTrending(filter: Filter): List? { return mangaNetworkDataSource.getTrending(filter).data?.map { it.toManga() } } suspend fun getManga(id: String, filter: Filter): Manga? { return mangaNetworkDataSource.getManga(id, filter)?.toManga() } fun mangaPager(filter: Filter, pageSize: Int) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), pagingSourceFactory = { MangaPagingDataSource(mangaNetworkDataSource, filter.pageLimit(pageSize)) } ).flow.map { pagingData -> pagingData.map { it.toManga() } } fun trendingMangaPager(filter: Filter, pageSize: Int) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), pagingSourceFactory = { TrendingMangaPagingDataSource(mangaNetworkDataSource, filter.pageLimit(pageSize)) } ).flow.map { pagingData -> pagingData.map { it.toManga() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/MappingRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.mapper.MappingMapper.toMapping import io.github.drumber.kitsune.data.presentation.model.mapping.Mapping import io.github.drumber.kitsune.data.source.network.mapping.MappingNetworkDataSource import io.github.drumber.kitsune.data.common.Filter class MappingRepository( private val mappingNetworkDataSource: MappingNetworkDataSource ) { suspend fun getAnimeMappings(animeId: String, filter: Filter = Filter()): List? { return mappingNetworkDataSource.getAnimeMappings(animeId, filter)?.map { it.toMapping() } } suspend fun getMangaMappings(mangaId: String, filter: Filter = Filter()): List? { return mappingNetworkDataSource.getMangaMappings(mangaId, filter)?.map { it.toMapping() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/MediaUnitRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.map import io.github.drumber.kitsune.constants.Repository import io.github.drumber.kitsune.data.mapper.MediaUnitMapper.toMediaUnit import io.github.drumber.kitsune.data.source.network.media.ChapterNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.ChapterPagingDataSource import io.github.drumber.kitsune.data.source.network.media.EpisodeNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.EpisodePagingDataSource import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.flow.map class MediaUnitRepository( private val episodeNetworkDataSource: EpisodeNetworkDataSource, private val chapterNetworkDataSource: ChapterNetworkDataSource ) { fun mediaUnitPager(type: MediaUnitType, filter: Filter, pageSize: Int) = Pager( config = PagingConfig( pageSize = pageSize, maxSize = Repository.MAX_CACHED_ITEMS ), pagingSourceFactory = { when (type) { MediaUnitType.EPISODE -> EpisodePagingDataSource(episodeNetworkDataSource, filter.pageLimit(pageSize)) MediaUnitType.CHAPTER -> ChapterPagingDataSource(chapterNetworkDataSource, filter.pageLimit(pageSize)) } } ).flow.map { pagingData -> pagingData.map { it.toMediaUnit() } } enum class MediaUnitType { EPISODE, CHAPTER } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/ProfileLinkRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.mapper.ProfileLinksMapper.toProfileLink import io.github.drumber.kitsune.data.mapper.ProfileLinksMapper.toProfileLinkSite import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLink import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLinkSite import io.github.drumber.kitsune.data.source.network.user.ProfileLinkNetworkDataSource import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLinkSite class ProfileLinkRepository( private val remoteProfileLinkDataSource: ProfileLinkNetworkDataSource ) { suspend fun getAllProfileLinkSites(filter: Filter): List? { return remoteProfileLinkDataSource.getAllProfileLinkSites(filter) ?.map { it.toProfileLinkSite() } } suspend fun createProfileLink(userId: String, siteId: String, url: String): ProfileLink? { val newProfileLink = NetworkProfileLink.empty().copy( url = url, profileLinkSite = NetworkProfileLinkSite( id = siteId, name = null ), user = NetworkUser(id = userId) ) return remoteProfileLinkDataSource.createProfileLink(newProfileLink)?.toProfileLink() } suspend fun updateProfileLink( userId: String, profileLinkId: String, url: String ): ProfileLink? { val updatedProfileLink = NetworkProfileLink.empty().copy( id = profileLinkId, url = url, user = NetworkUser(id = userId) ) return remoteProfileLinkDataSource.updateProfileLink(profileLinkId, updatedProfileLink) ?.toProfileLink() } suspend fun deleteProfileLink(id: String): Boolean { return remoteProfileLinkDataSource.deleteProfileLink(id) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/repository/UserRepository.kt ================================================ package io.github.drumber.kitsune.data.repository import io.github.drumber.kitsune.constants.Defaults import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.mapper.ProfileLinksMapper.toProfileLink import io.github.drumber.kitsune.data.mapper.UserMapper.toLocalUser import io.github.drumber.kitsune.data.mapper.UserMapper.toNetworkUser import io.github.drumber.kitsune.data.mapper.UserMapper.toUser import io.github.drumber.kitsune.data.presentation.model.user.User import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLink import io.github.drumber.kitsune.data.source.local.user.UserLocalDataSource import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.data.source.network.user.UserNetworkDataSource import io.github.drumber.kitsune.data.source.network.user.model.NetworkUserImageUpload import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class UserRepository( private val localUserDataSource: UserLocalDataSource, private val remoteUserDataSource: UserNetworkDataSource, private val coroutineScope: CoroutineScope ) { private val storeLocalUserMutex = Mutex() private val _localUser = MutableStateFlow(localUserDataSource.loadUser()) val localUser = _localUser.asStateFlow() private val _userReLogInPrompt = MutableSharedFlow() val userReLogInPrompt = _userReLogInPrompt.asSharedFlow() fun hasLocalUser() = _localUser.value != null fun clearLocalUser() { localUserDataSource.clearUser() _localUser.value = null } /** * Fetches the app user from the network and updates the local cached user object. */ suspend fun fetchAndStoreLocalUserFromNetwork() { val baseFilter = Filter() .include("waifu") .fields("characters", *Defaults.MINIMUM_CHARACTER_FIELDS) // fetch user model in the repository scope coroutineScope.async { val user = remoteUserDataSource.getSelf(baseFilter)?.toLocalUser() ?: throw NoDataException() storeLocalUser(user) }.await() } suspend fun storeLocalUser(user: LocalUser) { storeLocalUserMutex.withLock { localUserDataSource.storeUser(user) _localUser.value = user } } suspend fun promptUserReLogIn() { _userReLogInPrompt.emit(Unit) } suspend fun fetchUser(userId: String, filter: Filter): User? { return remoteUserDataSource.getUser(userId, filter)?.toUser() } suspend fun updateUser(userId: String, user: LocalUser): LocalUser? { return remoteUserDataSource.updateUser(userId, user.toNetworkUser())?.toLocalUser() } suspend fun updateUserImage(userId: String, avatar: String?, coverImage: String?): Boolean { val userImageUpload = NetworkUserImageUpload( id = userId, avatar = avatar, coverImage = coverImage ) return remoteUserDataSource.updateUserImage(userId, userImageUpload) } suspend fun deleteWaifuRelationship(userId: String): Boolean { return remoteUserDataSource.deleteWaifuRelationship(userId) } suspend fun getProfileLinksForUser(userId: String, filter: Filter): List? { return remoteUserDataSource.getProfileLinksForUser(userId, filter) ?.map { it.toProfileLink() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/LocalDatabase.kt ================================================ package io.github.drumber.kitsune.data.source.local import android.app.Application import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import io.github.drumber.kitsune.data.source.local.library.LocalLibraryConverters import io.github.drumber.kitsune.data.source.local.library.dao.LibraryEntryDao import io.github.drumber.kitsune.data.source.local.library.dao.LibraryEntryModificationDao import io.github.drumber.kitsune.data.source.local.library.dao.LibraryEntryWithModificationDao import io.github.drumber.kitsune.data.source.local.library.dao.RemoteKeyDao import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntry import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntryModification import io.github.drumber.kitsune.data.source.local.library.model.RemoteKeyEntity @Database( entities = [ LocalLibraryEntry::class, LocalLibraryEntryModification::class, RemoteKeyEntity::class ], version = 3, exportSchema = true ) @TypeConverters(LocalLibraryConverters::class) abstract class LocalDatabase : RoomDatabase() { abstract fun libraryEntryDao(): LibraryEntryDao abstract fun libraryEntryModificationDao(): LibraryEntryModificationDao abstract fun libraryEntryWithModificationDao(): LibraryEntryWithModificationDao abstract fun remoteKeyDao(): RemoteKeyDao companion object { fun createLocalDatabase(application: Application): LocalDatabase { return Room.databaseBuilder(application, LocalDatabase::class.java, "kitsune.db") .fallbackToDestructiveMigration() .build() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/auth/AccessTokenLocalDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.local.auth import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken interface AccessTokenLocalDataSource { fun loadAccessToken(): LocalAccessToken? fun storeAccessToken(accessToken: LocalAccessToken) fun clearAccessToken() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/auth/AccessTokenPreference.kt ================================================ package io.github.drumber.kitsune.data.source.local.auth import android.content.Context import androidx.core.content.edit import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.MasterKey import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken import io.github.drumber.kitsune.util.logI class AccessTokenPreference( context: Context, private val objectMapper: ObjectMapper ): AccessTokenLocalDataSource { private val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val sharedPrefsFile = context.getString(R.string.auth_preference_file_key) private val sharedPreferences = EncryptedSharedPreferences.create( context, sharedPrefsFile, masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) override fun storeAccessToken(accessToken: LocalAccessToken) { logI("Converting access token to json and storing it in encrypted shared preferences.") val jsonString = objectMapper.writeValueAsString(accessToken) sharedPreferences.edit(commit = true) { putString(KEY_ACCESS_TOKEN, jsonString) } } override fun clearAccessToken() { logI("Deleting access token from encrypted shared preferences.") sharedPreferences.edit(commit = true) { remove(KEY_ACCESS_TOKEN) } } override fun loadAccessToken(): LocalAccessToken? { val jsonString = sharedPreferences.getString(KEY_ACCESS_TOKEN, null) return if (!jsonString.isNullOrBlank()) { logI("Parse and return access token stored as json.") objectMapper.readValue(jsonString) } else { logI("No access token stored.") null } } companion object { const val KEY_ACCESS_TOKEN = "access_token" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/auth/model/LocalAccessToken.kt ================================================ package io.github.drumber.kitsune.data.source.local.auth.model import com.fasterxml.jackson.annotation.JsonProperty data class LocalAccessToken( @JsonProperty("access_token") val accessToken: String, @JsonProperty("created_at") val createdAt: Long, @JsonProperty("expires_in") val expiresIn: Long, @JsonProperty("refresh_token") val refreshToken: String ) { fun getExpirationTimeInSeconds(): Long { return createdAt + expiresIn } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/character/LocalCharacter.kt ================================================ package io.github.drumber.kitsune.data.source.local.character import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles data class LocalCharacter( val id: String, val slug: String?, val name: String?, val names: Titles?, val otherNames: List?, val malId: Int?, val description: String?, val image: Image? ) { companion object { fun empty(id: String) = LocalCharacter( id = id, slug = null, name = null, names = null, otherNames = null, malId = null, description = null, image = null ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/LibraryLocalDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.local.library import androidx.paging.PagingSource import androidx.room.withTransaction import io.github.drumber.kitsune.data.common.library.LibraryEntryKind import io.github.drumber.kitsune.data.source.local.LocalDatabase import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntry import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntryModification import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryMedia.MediaType import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryModificationState import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryStatus import io.github.drumber.kitsune.data.source.local.library.model.RemoteKeyType class LibraryLocalDataSource( private val database: LocalDatabase ) { private val libraryEntryDao get() = database.libraryEntryDao() private val libraryEntryModificationDao get() = database.libraryEntryModificationDao() private val libraryEntryWithModificationDao get() = database.libraryEntryWithModificationDao() private val remoteKeyDao get() = database.remoteKeyDao() //********************************************************************************************// // LibraryEntry related methods //********************************************************************************************// suspend fun getLibraryEntry(id: String) = libraryEntryDao.getLibraryEntry(id) suspend fun getLibraryEntryFromMedia(mediaId: String) = libraryEntryDao.getLibraryEntryFromMedia(mediaId) suspend fun getLibraryEntriesWithModificationsByStatus(status: List) = libraryEntryWithModificationDao.getLibraryEntriesWithModificationByStatus(status) fun getLibraryEntriesWithModificationsByStatusAsFlow(status: List) = libraryEntryWithModificationDao.getLibraryEntriesWithModificationByStatusAsFlow(status) fun getLibraryEntryWithModificationFromMediaAsLiveData(mediaId: String) = libraryEntryWithModificationDao.getLibraryEntryWithModificationFromMediaAsLiveData(mediaId) suspend fun insertAllLibraryEntries(libraryEntries: List) { database.withTransaction { libraryEntries.forEach { insertLibraryEntry(it) } } } suspend fun insertLibraryEntry(libraryEntry: LocalLibraryEntry) { libraryEntry.verifyIsValidLibraryEntry() insertLibraryEntryIfUpdatedAtIsNewer(libraryEntry) } suspend fun insertLibraryEntryIfUpdatedAtIsNewer(libraryEntry: LocalLibraryEntry): Boolean { libraryEntry.verifyIsValidLibraryEntry() if (libraryEntry.updatedAt.isNullOrBlank()) { insertLibraryEntry(libraryEntry) return true } return database.withTransaction { val hasNewerEntry = libraryEntryDao.hasLibraryEntryWhereUpdatedAtIsAfter( libraryEntry.id, libraryEntry.updatedAt ) if (!hasNewerEntry) { libraryEntryDao.insertSingle(libraryEntry) true } else { // do not overwrite more up-to-date library entry false } } } suspend fun getLibraryEntriesByKindAndStatus( kind: LibraryEntryKind, status: List ): List { val hasStatus = status.isNotEmpty() return with(libraryEntryDao) { when { kind == LibraryEntryKind.Anime && hasStatus -> getAllLibraryEntriesByTypeAndStatus( MediaType.Anime, status ) kind == LibraryEntryKind.Anime && !hasStatus -> getAllLibraryEntriesByType( MediaType.Anime ) kind == LibraryEntryKind.Manga && hasStatus -> getAllLibraryEntriesByTypeAndStatus( MediaType.Manga, status ) kind == LibraryEntryKind.Manga && !hasStatus -> getAllLibraryEntriesByType( MediaType.Manga ) kind == LibraryEntryKind.All && hasStatus -> getAllLibraryEntriesByStatus( status ) else -> getAllLibraryEntries() } } } fun getLibraryEntriesByKindAndStatusAsPagingSource( kind: LibraryEntryKind, status: List ): PagingSource { val hasStatus = status.isNotEmpty() return with(libraryEntryDao) { when { kind == LibraryEntryKind.Anime && hasStatus -> allLibraryEntriesByTypeAndStatusPagingSource( MediaType.Anime, status ) kind == LibraryEntryKind.Anime && !hasStatus -> allLibraryEntriesByTypePagingSource( MediaType.Anime ) kind == LibraryEntryKind.Manga && hasStatus -> allLibraryEntriesByTypeAndStatusPagingSource( MediaType.Manga, status ) kind == LibraryEntryKind.Manga && !hasStatus -> allLibraryEntriesByTypePagingSource( MediaType.Manga ) kind == LibraryEntryKind.All && hasStatus -> allLibraryEntriesByStatusPagingSource( status ) else -> allLibraryEntriesPagingSource() } } } //********************************************************************************************// // LibraryEntryModification related methods //********************************************************************************************// suspend fun getLibraryEntryModification(id: String) = libraryEntryModificationDao.getLibraryEntryModification(id) suspend fun getAllLocalLibraryModifications(): List { return libraryEntryModificationDao.getAllLibraryEntryModifications() } fun getAllLibraryEntryModificationsAsFlow() = libraryEntryModificationDao.getAllLibraryEntryModificationsAsFlow() fun getLibraryEntryModificationsByStateAsLiveData(state: LocalLibraryModificationState) = libraryEntryModificationDao.getLibraryEntryModificationsByStateAsLiveData(state) suspend fun insertLibraryEntryModification( libraryEntryModification: LocalLibraryEntryModification ) { libraryEntryModificationDao.insertSingle(libraryEntryModification) } suspend fun deleteLibraryEntryModification( libraryEntryModification: LocalLibraryEntryModification ) { libraryEntryModificationDao.deleteSingle(libraryEntryModification) } suspend fun updateLibraryEntryAndDeleteModification( libraryEntry: LocalLibraryEntry, libraryEntryModification: LocalLibraryEntryModification ) { database.withTransaction { libraryEntryDao.updateSingle(libraryEntry) libraryEntryModificationDao.deleteSingleMatchingCreateTime( libraryEntryModification.id, libraryEntryModification.createTime ) } } suspend fun deleteLibraryEntryAndAnyModification(libraryEntryId: String) { database.withTransaction { libraryEntryDao.deleteSingleById(libraryEntryId) libraryEntryModificationDao.deleteSingleById(libraryEntryId) } } //********************************************************************************************// // RemoteKey related methods //********************************************************************************************// suspend fun getRemoteKeyByResourceId(resourceId: String, remoteKeyType: RemoteKeyType) = remoteKeyDao.getRemoteKeyByResourceId(resourceId, remoteKeyType) suspend fun deleteRemoteKeyByResourceId(resourceId: String, remoteKeyType: RemoteKeyType) { remoteKeyDao.deleteByResourceId(resourceId, remoteKeyType) } //********************************************************************************************// // Utilities //********************************************************************************************// suspend fun runDatabaseTransaction(block: suspend LocalDatabase.() -> R) = database.withTransaction { block(database) } /** * Verifies that the library entry has an ID and contains a media object. * * @throws IllegalArgumentException if the library entry is not valid */ private fun LocalLibraryEntry.verifyIsValidLibraryEntry() { requireNotNull(this.id) requireNotNull(this.media) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/LocalLibraryConverters.kt ================================================ package io.github.drumber.kitsune.data.source.local.library import androidx.room.TypeConverter import com.fasterxml.jackson.module.kotlin.readValue import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.common.media.AnimeSubtype import io.github.drumber.kitsune.data.common.media.MangaSubtype import io.github.drumber.kitsune.data.common.media.ReleaseStatus import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryStatus import io.github.drumber.kitsune.data.source.local.library.model.LocalReactionSkip import io.github.drumber.kitsune.data.source.local.library.model.RemoteKeyType import io.github.drumber.kitsune.di.createObjectMapper class LocalLibraryConverters { private val objectMapper = createObjectMapper() @TypeConverter fun stringListToString(list: List?): String? { return if (list != null) { objectMapper.writeValueAsString(list) } else { null } } @TypeConverter fun stringToStringList(listJson: String?): List? { return if (listJson != null) { objectMapper.readValue(listJson) } else { null } } @TypeConverter fun stringMapToString(map: Map?): String? { return if (map != null) { objectMapper.writeValueAsString(map) } else { null } } @TypeConverter fun stringToStringMap(mapJson: String?): Map? { return if (mapJson != null) { objectMapper.readValue(mapJson) } else { null } } @TypeConverter fun ageRatingToString(ageRating: AgeRating?): String? { return if (ageRating != null) { objectMapper.writeValueAsString(ageRating) } else { null } } @TypeConverter fun stringToAgeRating(json: String?): AgeRating? { return if (json != null) { objectMapper.readValue(json) } else { null } } @TypeConverter fun animeSubtypeToString(animeSubtype: AnimeSubtype?): String? { return if (animeSubtype != null) { objectMapper.writeValueAsString(animeSubtype) } else { null } } @TypeConverter fun stringToAnimeSubtype(json: String?): AnimeSubtype? { return if (json != null) { objectMapper.readValue(json) } else { null } } @TypeConverter fun mangaSubtypeToString(mangaSubtype: MangaSubtype?): String? { return if (mangaSubtype != null) { objectMapper.writeValueAsString(mangaSubtype) } else { null } } @TypeConverter fun stringToMangaSubtype(json: String?): MangaSubtype? { return if (json != null) { objectMapper.readValue(json) } else { null } } @TypeConverter fun statusToString(status: ReleaseStatus?): String? { return if (status != null) { objectMapper.writeValueAsString(status) } else { null } } @TypeConverter fun stringToStatus(json: String?): ReleaseStatus? { return if (json != null) { objectMapper.readValue(json) } else { null } } @TypeConverter fun libraryStatusToOrderId(status: LocalLibraryStatus?): Int? { return status?.orderId } @TypeConverter fun orderIdToLibraryStatus(orderId: Int?): LocalLibraryStatus? { return if (orderId != null) { LocalLibraryStatus.entries.find { it.orderId == orderId } } else { null } } @TypeConverter fun reactionSkipToString(reactionSkip: LocalReactionSkip?): String? { return if (reactionSkip != null) { objectMapper.writeValueAsString(reactionSkip) } else { null } } @TypeConverter fun stringToReactionSkip(json: String?): LocalReactionSkip? { return if (json != null) { objectMapper.readValue(json) } else { null } } @TypeConverter fun remoteKeyTypeToString(remoteKeyType: RemoteKeyType?): String? { return if (remoteKeyType != null) { objectMapper.writeValueAsString(remoteKeyType) } else { null } } @TypeConverter fun stringToRemoteKeyType(json: String?): RemoteKeyType? { return if (json != null) { objectMapper.readValue(json) } else { null } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/dao/LibraryEntryDao.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.dao import androidx.lifecycle.LiveData import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntry import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryMedia import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryStatus @Dao interface LibraryEntryDao { companion object { /** Order by status orderId (see [LocalLibraryStatus]) and update time */ const val ORDER_BY_STATUS = "ORDER BY status, DATETIME(updatedAt) DESC" } /* =================== * All Library Status * =================== */ @Query("SELECT * FROM library_entries $ORDER_BY_STATUS") fun allLibraryEntriesPagingSource(): PagingSource @Query("SELECT * FROM library_entries WHERE media_type = :type $ORDER_BY_STATUS") fun allLibraryEntriesByTypePagingSource(type: LocalLibraryMedia.MediaType): PagingSource /* =================== * Filtered by Status * =================== */ @Query("SELECT * FROM library_entries WHERE status IN (:status) $ORDER_BY_STATUS") fun allLibraryEntriesByStatusPagingSource(status: List): PagingSource @Query("SELECT * FROM library_entries WHERE status IN (:status) AND media_type = :type $ORDER_BY_STATUS") fun allLibraryEntriesByTypeAndStatusPagingSource(type: LocalLibraryMedia.MediaType, status: List): PagingSource /* =================== * Non Paging Queries * =================== */ @Query("SELECT * FROM library_entries $ORDER_BY_STATUS") suspend fun getAllLibraryEntries(): List @Query("SELECT * FROM library_entries WHERE media_type = :type $ORDER_BY_STATUS") suspend fun getAllLibraryEntriesByType(type: LocalLibraryMedia.MediaType): List @Query("SELECT * FROM library_entries WHERE status IN (:status) $ORDER_BY_STATUS") suspend fun getAllLibraryEntriesByStatus(status: List): List @Query("SELECT * FROM library_entries WHERE status IN (:status) AND media_type = :type $ORDER_BY_STATUS") suspend fun getAllLibraryEntriesByTypeAndStatus(type: LocalLibraryMedia.MediaType, status: List): List @Query("SELECT * FROM library_entries WHERE media_id = :mediaId") suspend fun getLibraryEntryFromMedia(mediaId: String): LocalLibraryEntry? @Query("SELECT * FROM library_entries WHERE media_id = :mediaId") fun getLibraryEntryFromMediaAsLiveData(mediaId: String): LiveData @Query("SELECT * FROM library_entries WHERE id = :id") suspend fun getLibraryEntry(id: String): LocalLibraryEntry? @Query("SELECT EXISTS(SELECT 1 FROM library_entries WHERE id = :id AND DATETIME(updatedAt) > DATETIME(:updatedAt))") suspend fun hasLibraryEntryWhereUpdatedAtIsAfter(id: String, updatedAt: String): Boolean @Query("SELECT * FROM library_entries WHERE id = :id") fun getLibraryEntryAsLiveData(id: String): LiveData @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(libraryEntry: List) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSingle(libraryEntry: LocalLibraryEntry) @Update suspend fun updateSingle(libraryEntry: LocalLibraryEntry) @Delete suspend fun deleteSingle(libraryEntry: LocalLibraryEntry) @Query("DELETE FROM library_entries WHERE id = :id") suspend fun deleteSingleById(id: String) @Delete suspend fun deleteAll(libraryEntries: List) @Query("DELETE FROM library_entries") suspend fun clearLibraryEntries() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/dao/LibraryEntryModificationDao.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.dao import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntryModification import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryModificationState import kotlinx.coroutines.flow.Flow @Dao interface LibraryEntryModificationDao { @Query("SELECT * FROM library_entries_modifications") fun getAllLibraryEntryModificationsAsFlow(): Flow> @Query("SELECT * FROM library_entries_modifications WHERE state = :state") fun getLibraryEntryModificationsByStateAsLiveData(state: LocalLibraryModificationState): LiveData> @Query("SELECT * FROM library_entries_modifications") suspend fun getAllLibraryEntryModifications(): List @Query("SELECT * FROM library_entries_modifications WHERE id = :id") suspend fun getLibraryEntryModification(id: String): LocalLibraryEntryModification? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertSingle(libraryEntryModification: LocalLibraryEntryModification) @Update suspend fun updateSingle(libraryEntryModification: LocalLibraryEntryModification) @Delete suspend fun deleteSingle(libraryEntryModification: LocalLibraryEntryModification) @Query("DELETE FROM library_entries_modifications WHERE id = :id") suspend fun deleteSingleById(id: String) @Query("DELETE FROM library_entries_modifications WHERE id = :id AND createTime = :createTime") suspend fun deleteSingleMatchingCreateTime(id: String, createTime: Long) @Query("DELETE FROM library_entries_modifications") suspend fun clearAll() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/dao/LibraryEntryWithModificationDao.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.dao import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import io.github.drumber.kitsune.data.source.local.library.dao.LibraryEntryDao.Companion.ORDER_BY_STATUS import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryEntryWithModification import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryStatus import kotlinx.coroutines.flow.Flow @Dao interface LibraryEntryWithModificationDao { @Transaction @Query("SELECT * FROM library_entries WHERE media_id = :mediaId") suspend fun getLibraryEntryWithModificationFromMedia(mediaId: String): LocalLibraryEntryWithModification? @Transaction @Query("SELECT * FROM library_entries WHERE media_id = :mediaId") fun getLibraryEntryWithModificationFromMediaAsLiveData(mediaId: String): LiveData @Transaction @Query("SELECT * FROM library_entries WHERE status IN (:status) $ORDER_BY_STATUS") suspend fun getLibraryEntriesWithModificationByStatus(status: List): List @Transaction @Query("SELECT * FROM library_entries WHERE status IN (:status) $ORDER_BY_STATUS") fun getLibraryEntriesWithModificationByStatusAsFlow(status: List): Flow> } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/dao/RemoteKeyDao.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import io.github.drumber.kitsune.data.source.local.library.model.RemoteKeyEntity import io.github.drumber.kitsune.data.source.local.library.model.RemoteKeyType @Dao interface RemoteKeyDao { @Query("SELECT * FROM remote_keys WHERE resourceId = :resourceId AND remoteKeyType = :remoteKeyType") suspend fun getRemoteKeyByResourceId(resourceId: String, remoteKeyType: RemoteKeyType): RemoteKeyEntity? @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertALl(key: List) @Query("DELETE FROM remote_keys WHERE resourceId = :resourceId AND remoteKeyType = :remoteKeyType") suspend fun deleteByResourceId(resourceId: String, remoteKeyType: RemoteKeyType) @Query("DELETE FROM remote_keys WHERE remoteKeyType = :remoteKeyType AND resourceId IN (:resourceIds)") suspend fun deleteAllByResourceId(resourceIds: List, remoteKeyType: RemoteKeyType) @Query("DELETE FROM remote_keys WHERE remoteKeyType = :remoteKeyType") suspend fun clearRemoteKeys(remoteKeyType: RemoteKeyType) @Query("DELETE FROM remote_keys") suspend fun clearAllRemoteKeys() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalImage.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model import androidx.room.Embedded data class LocalImage( val tiny: String?, val small: String?, val medium: String?, val large: String?, val original: String?, @Embedded(prefix = "meta_") val meta: LocalImageMeta? ) data class LocalImageMeta(@Embedded val dimensions: LocalDimensions?) data class LocalDimensions( @Embedded(prefix = "tiny_") val tiny: LocalDimension?, @Embedded(prefix = "small_") val small: LocalDimension?, @Embedded(prefix = "medium_") val medium: LocalDimension?, @Embedded(prefix = "large_") val large: LocalDimension? ) data class LocalDimension(val width: Int?, val height: Int?) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalLibraryEntry.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model import androidx.room.Embedded import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "library_entries") data class LocalLibraryEntry( @PrimaryKey val id: String, val updatedAt: String?, val startedAt: String?, val finishedAt: String?, val progressedAt: String?, val status: LocalLibraryStatus?, val progress: Int?, val reconsuming: Boolean?, val reconsumeCount: Int?, val volumesOwned: Int?, val ratingTwenty: Int?, val notes: String?, val privateEntry: Boolean?, val reactionSkipped: LocalReactionSkip?, @Embedded("media_") val media: LocalLibraryMedia? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalLibraryEntryModification.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model import androidx.room.Entity import androidx.room.PrimaryKey import io.github.drumber.kitsune.data.source.local.library.model.LocalLibraryModificationState.NOT_SYNCHRONIZED @Entity(tableName = "library_entries_modifications") data class LocalLibraryEntryModification( /** Corresponds to the library entry ID */ @PrimaryKey val id: String, val createTime: Long = System.currentTimeMillis(), val state: LocalLibraryModificationState = NOT_SYNCHRONIZED, val startedAt: String?, val finishedAt: String?, val status: LocalLibraryStatus?, val progress: Int?, val reconsumeCount: Int?, val volumesOwned: Int?, /** Set to `-1` to remove rating (will be mapped to `null` by the json serializer) */ val ratingTwenty: Int?, val notes: String?, val privateEntry: Boolean? ) { fun isEqualToLibraryEntry(localLibraryEntry: LocalLibraryEntry): Boolean { return id == localLibraryEntry.id && applyToLibraryEntry(localLibraryEntry) == localLibraryEntry } fun applyToLibraryEntry(localLibraryEntry: LocalLibraryEntry): LocalLibraryEntry { require(id == localLibraryEntry.id) { "ID of the library modification and the entry must be the same." } return localLibraryEntry.copy( startedAt = startedAt ?: localLibraryEntry.startedAt, finishedAt = finishedAt ?: localLibraryEntry.finishedAt, status = status ?: localLibraryEntry.status, progress = progress ?: localLibraryEntry.progress, reconsumeCount = reconsumeCount ?: localLibraryEntry.reconsumeCount, volumesOwned = volumesOwned ?: localLibraryEntry.volumesOwned, ratingTwenty = ratingTwenty ?: localLibraryEntry.ratingTwenty, notes = notes?.takeIf { it.isNotBlank() || !localLibraryEntry.notes.isNullOrBlank() } ?: localLibraryEntry.notes, privateEntry = privateEntry ?: localLibraryEntry.privateEntry ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalLibraryEntryWithModification.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model import androidx.room.Embedded import androidx.room.Relation data class LocalLibraryEntryWithModification( @Embedded val libraryEntry: LocalLibraryEntry, @Relation( parentColumn = "id", entityColumn = "id" ) val libraryEntryModification: LocalLibraryEntryModification? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalLibraryMedia.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model import androidx.room.Embedded import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.common.media.AnimeSubtype import io.github.drumber.kitsune.data.common.media.MangaSubtype import io.github.drumber.kitsune.data.common.media.RatingFrequencies import io.github.drumber.kitsune.data.common.media.ReleaseStatus data class LocalLibraryMedia( val id: String, val type: MediaType, val description: String?, val titles: Titles?, val canonicalTitle: String?, val abbreviatedTitles: List?, val averageRating: String?, @Embedded(prefix = "rating_") val ratingFrequencies: RatingFrequencies?, val popularityRank: Int?, val ratingRank: Int?, val startDate: String?, val endDate: String?, val nextRelease: String?, val tba: String?, val status: ReleaseStatus?, val ageRating: AgeRating?, val ageRatingGuide: String?, val nsfw: Boolean?, @Embedded(prefix = "poster_") val posterImage: LocalImage?, @Embedded(prefix = "cover_") val coverImage: LocalImage?, // Anime specific attributes val animeSubtype: AnimeSubtype?, val totalLength: Int?, val episodeCount: Int?, val episodeLength: Int?, // Manga specific attributes val mangaSubtype: MangaSubtype?, val chapterCount: Int?, val volumeCount: Int?, val serialization: String? ) { enum class MediaType { Anime, Manga } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalLibraryModificationState.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model enum class LocalLibraryModificationState { SYNCHRONIZING, NOT_SYNCHRONIZED } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalLibraryStatus.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model enum class LocalLibraryStatus(val orderId: Int) { Current(0), Planned(1), Completed(2), OnHold(3), Dropped(4) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/LocalReactionSkip.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model enum class LocalReactionSkip { Unskipped, Skipped, Ignored } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/library/model/RemoteKeyEntity.kt ================================================ package io.github.drumber.kitsune.data.source.local.library.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @Entity(tableName = "remote_keys") data class RemoteKeyEntity( @PrimaryKey @ColumnInfo(collate = ColumnInfo.NOCASE) val resourceId: String, val remoteKeyType: RemoteKeyType, val prevPageKey: Int?, val nextPageKey: Int? ) enum class RemoteKeyType { LibraryEntry } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/user/UserLocalDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.local.user import io.github.drumber.kitsune.data.source.local.user.model.LocalUser interface UserLocalDataSource { fun loadUser(): LocalUser? fun storeUser(user: LocalUser) fun clearUser() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/user/UserPreferences.kt ================================================ package io.github.drumber.kitsune.data.source.local.user import android.content.Context import androidx.core.content.edit import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.util.logD class UserPreferences(context: Context, private val objectMapper: ObjectMapper) : UserLocalDataSource { private val sharedPreferences = context.getSharedPreferences( context.getString(R.string.user_preference_file_key), Context.MODE_PRIVATE ) override fun loadUser(): LocalUser? { val jsonString = sharedPreferences.getString(KEY_USER_MODEL, null) return if (!jsonString.isNullOrBlank()) { logD("Parse and return user model stored as json string.") return objectMapper.readValue(jsonString) } else { logD("No user model stored.") null } } override fun storeUser(user: LocalUser) { logD("Storing user model in shared preferences.") val jsonString = objectMapper.writeValueAsString(user) sharedPreferences.edit { putString(KEY_USER_MODEL, jsonString) } } override fun clearUser() { logD("Deleting user model from shared preferences.") sharedPreferences.edit { remove(KEY_USER_MODEL) } } companion object { const val KEY_USER_MODEL = "user_model" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/user/model/LocalRatingSystemPreference.kt ================================================ package io.github.drumber.kitsune.data.source.local.user.model enum class LocalRatingSystemPreference { // 0.5, 1...10 Advanced, // 0.5, 1...5 Regular, // :(, :|, :), :D Simple } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/user/model/LocalSfwFilterPreference.kt ================================================ package io.github.drumber.kitsune.data.source.local.user.model enum class LocalSfwFilterPreference { SFW, NSFW_SOMETIMES, NSFW_EVERYWHERE } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/user/model/LocalTitleLanguagePreference.kt ================================================ package io.github.drumber.kitsune.data.source.local.user.model enum class LocalTitleLanguagePreference { Canonical, Romanized, English } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/local/user/model/LocalUser.kt ================================================ package io.github.drumber.kitsune.data.source.local.user.model import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.user.UserThemePreference import io.github.drumber.kitsune.data.source.local.character.LocalCharacter data class LocalUser( val id: String, val createdAt: String?, val name: String?, val slug: String?, val email: String?, val title: String?, val avatar: Image?, val coverImage: Image?, val about: String?, val location: String?, val gender: String?, val birthday: String?, val waifuOrHusbando: String?, val followersCount: Int?, val followingCount: Int?, val country: String?, val language: String?, val timeZone: String?, val theme: UserThemePreference?, val sfwFilter: Boolean?, val ratingSystem: LocalRatingSystemPreference?, val sfwFilterPreference: LocalSfwFilterPreference?, val titleLanguagePreference: LocalTitleLanguagePreference?, val waifu: LocalCharacter? ) { companion object { fun empty(id: String) = LocalUser( id = id, createdAt = null, name = null, slug = null, email = null, title = null, avatar = null, coverImage = null, about = null, location = null, gender = null, birthday = null, waifuOrHusbando = null, followersCount = null, followingCount = null, country = null, language = null, timeZone = null, theme = null, sfwFilter = null, ratingSystem = null, sfwFilterPreference = null, titleLanguagePreference = null, waifu = null ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/BasePagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network import androidx.paging.PagingSource import androidx.paging.PagingState import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.util.logE abstract class BasePagingDataSource : PagingSource() { override suspend fun load(params: LoadParams): LoadResult { return try { val pageOffset = params.key ?: Kitsu.DEFAULT_PAGE_OFFSET val pageData = requestPage(pageOffset) val data = pageData.data ?: throw NoDataException("Received data is 'null'.") LoadResult.Page( data = data, prevKey = pageData.prev, nextKey = pageData.next ) } catch (e: Exception) { logE("Error receiving data from API.", e) LoadResult.Error(e) } } abstract suspend fun requestPage(pageOffset: Int): PageData override fun getRefreshKey(state: PagingState) = state.anchorPosition?.let { anchorPosition -> state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/PageData.kt ================================================ package io.github.drumber.kitsune.data.source.network import android.net.Uri import com.github.jasminb.jsonapi.JSONAPIDocument data class PageData( val data: List?, val first: Int?, val last: Int?, val prev: Int?, val next: Int? ) fun JSONAPIDocument>.toPageData() = PageData( data = get(), first = links?.first?.href?.parseOffset(), last = links?.last?.href?.parseOffset(), next = links?.next?.href?.parseOffset(), prev = links?.previous?.href?.parseOffset() ) private fun String.parseOffset() = Uri.parse(this).getQueryParameter("page[offset]")?.toInt() ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/AlgoliaKeyNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia import io.github.drumber.kitsune.data.source.network.algolia.api.AlgoliaKeyApi import io.github.drumber.kitsune.data.source.network.algolia.model.NetworkAlgoliaKeyCollection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class AlgoliaKeyNetworkDataSource( private val algoliaKeyApi: AlgoliaKeyApi ) { suspend fun getAllAlgoliaKeys(): NetworkAlgoliaKeyCollection { return withContext(Dispatchers.IO) { algoliaKeyApi.getAllAlgoliaKeys() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/api/AlgoliaKeyApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia.api import io.github.drumber.kitsune.data.source.network.algolia.model.NetworkAlgoliaKeyCollection import retrofit2.http.GET interface AlgoliaKeyApi { @GET("algolia-keys") suspend fun getAllAlgoliaKeys(): NetworkAlgoliaKeyCollection } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/model/NetworkAlgoliaKey.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia.model data class NetworkAlgoliaKey( val key: String?, val index: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/model/NetworkAlgoliaKeyCollection.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia.model data class NetworkAlgoliaKeyCollection( val users: NetworkAlgoliaKey?, val posts: NetworkAlgoliaKey?, val media: NetworkAlgoliaKey?, val groups: NetworkAlgoliaKey?, val characters: NetworkAlgoliaKey? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/model/search/AlgoliaCharacterSearchResult.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia.model.search import kotlinx.serialization.Serializable @Serializable data class AlgoliaCharacterSearchResult( val id: Long, val slug: String? = null, val canonicalName: String? = null, val image: AlgoliaImage? = null, val primaryMedia: String? = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/model/search/AlgoliaImage.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia.model.search import kotlinx.serialization.Serializable @Serializable data class AlgoliaImage( val tiny: String? = null, val small: String? = null, val medium: String? = null, val large: String? = null, val original: String? = null, val meta: AlgoliaImageMeta? = null, ) @Serializable data class AlgoliaImageMeta(val dimensions: AlgoliaDimensions? = null) @Serializable data class AlgoliaDimensions( val tiny: AlgoliaDimension? = null, val small: AlgoliaDimension? = null, val medium: AlgoliaDimension? = null, val large: AlgoliaDimension? = null ) @Serializable data class AlgoliaDimension(val width: Int? = null, val height: Int? = null) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/model/search/AlgoliaMediaSearchKind.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia.model.search import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable enum class AlgoliaMediaSearchKind { @SerialName("anime") Anime, @SerialName("manga") Manga } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/algolia/model/search/AlgoliaMediaSearchResult.kt ================================================ package io.github.drumber.kitsune.data.source.network.algolia.model.search import io.github.drumber.kitsune.data.common.Titles import kotlinx.serialization.Serializable @Serializable data class AlgoliaMediaSearchResult( val id: Long, val kind: AlgoliaMediaSearchKind, val subtype: String? = null, val slug: String? = null, val titles: Titles? = null, val canonicalTitle: String? = null, val posterImage: AlgoliaImage? = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/appupdate/AppReleaseNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.appupdate import io.github.drumber.kitsune.data.source.network.appupdate.api.GitHubApi import io.github.drumber.kitsune.data.source.network.appupdate.model.NetworkGitHubRelease import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class AppReleaseNetworkDataSource( private val service: GitHubApi ) { suspend fun getLatestRelease(): NetworkGitHubRelease { return withContext(Dispatchers.IO) { service.getLatestRelease() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/appupdate/api/GitHubApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.appupdate.api import io.github.drumber.kitsune.data.source.network.appupdate.model.NetworkGitHubRelease import retrofit2.http.GET interface GitHubApi { @GET("releases/latest") suspend fun getLatestRelease(): NetworkGitHubRelease } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/appupdate/model/NetworkGitHubRelease.kt ================================================ package io.github.drumber.kitsune.data.source.network.appupdate.model import com.fasterxml.jackson.annotation.JsonProperty data class NetworkGitHubRelease( @JsonProperty("tag_name") val version: String, @JsonProperty("html_url") val url: String, @JsonProperty("published_at") val publishDate: String ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/auth/AccessTokenNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.auth import io.github.drumber.kitsune.data.source.network.auth.api.AuthenticationApi import io.github.drumber.kitsune.data.source.network.auth.model.NetworkAccessToken import io.github.drumber.kitsune.data.source.network.auth.model.ObtainAccessToken import io.github.drumber.kitsune.data.source.network.auth.model.RefreshAccessToken import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class AccessTokenNetworkDataSource( private val authenticationApi: AuthenticationApi ) { suspend fun obtainAccessToken(obtainAccessToken: ObtainAccessToken): NetworkAccessToken { return withContext(Dispatchers.IO) { authenticationApi.obtainAccessToken(obtainAccessToken) } } suspend fun refreshToken(refreshAccessToken: RefreshAccessToken): NetworkAccessToken { return withContext(Dispatchers.IO) { authenticationApi.refreshToken(refreshAccessToken) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/auth/api/AuthenticationApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.auth.api import io.github.drumber.kitsune.data.source.network.auth.model.NetworkAccessToken import io.github.drumber.kitsune.data.source.network.auth.model.ObtainAccessToken import io.github.drumber.kitsune.data.source.network.auth.model.RefreshAccessToken import retrofit2.http.Body import retrofit2.http.POST interface AuthenticationApi { @POST("token") suspend fun obtainAccessToken( @Body obtainAccessToken: ObtainAccessToken ): NetworkAccessToken @POST("token") suspend fun refreshToken( @Body refreshAccessToken: RefreshAccessToken ): NetworkAccessToken } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/auth/model/NetworkAccessToken.kt ================================================ package io.github.drumber.kitsune.data.source.network.auth.model import com.fasterxml.jackson.annotation.JsonProperty /** * @param createdAt time in seconds the access token was created * @param expiresIn seconds until the [accessToken] expires (30 days default) */ data class NetworkAccessToken( @JsonProperty("access_token") val accessToken: String?, @JsonProperty("created_at") val createdAt: Long?, @JsonProperty("expires_in") val expiresIn: Long?, @JsonProperty("refresh_token") val refreshToken: String?, @JsonProperty("scope") val scope: String?, @JsonProperty("token_type") val tokenType: String?, ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/auth/model/ObtainAccessToken.kt ================================================ package io.github.drumber.kitsune.data.source.network.auth.model import com.fasterxml.jackson.annotation.JsonProperty data class ObtainAccessToken( @JsonProperty("grant_type") val grantType: String = "password", @JsonProperty("username") val username: String, @JsonProperty("password") val password: String ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/auth/model/RefreshAccessToken.kt ================================================ package io.github.drumber.kitsune.data.source.network.auth.model import com.fasterxml.jackson.annotation.JsonProperty data class RefreshAccessToken( @JsonProperty("grant_type") val grantType: String = "refresh_token", @JsonProperty("refresh_token") val refreshToken: String ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/character/CharacterNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.character import io.github.drumber.kitsune.data.source.network.character.api.CharacterApi import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class CharacterNetworkDataSource( private val characterApi: CharacterApi ) { suspend fun getCharacter(id: String, filter: Filter): NetworkCharacter? { return withContext(Dispatchers.IO) { characterApi.getCharacter(id, filter.options).get() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/character/api/CharacterApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.character.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface CharacterApi { @GET("characters/{id}") suspend fun getCharacter( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/character/model/NetworkCharacter.kt ================================================ package io.github.drumber.kitsune.data.source.network.character.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavoriteItem @Type("characters") data class NetworkCharacter( @Id val id: String?, val slug: String? = null, val name: String? = null, val names: Titles? = null, val otherNames: List? = null, val malId: Int? = null, val description: String? = null, val image: Image? = null, @Relationship("mediaCharacters") val mediaCharacters: List? = null ) : NetworkFavoriteItem ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/character/model/NetworkMediaCharacter.kt ================================================ package io.github.drumber.kitsune.data.source.network.character.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.source.network.media.model.NetworkMedia @Type("mediaCharacters") data class NetworkMediaCharacter( @Id val id: String?, val role: NetworkMediaCharacterRole?, @Relationship("media") val media: NetworkMedia? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/character/model/NetworkMediaCharacterRole.kt ================================================ package io.github.drumber.kitsune.data.source.network.character.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkMediaCharacterRole { @JsonProperty("main") MAIN, @JsonProperty("supporting") SUPPORTING, @JsonProperty("recurring") RECURRING, @JsonProperty("cameo") CAMEO } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/library/LibraryEntryPagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.library import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.library.model.NetworkLibraryEntry import io.github.drumber.kitsune.data.common.Filter class LibraryEntryPagingDataSource( private val dataSource: LibraryNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getAllLibraryEntries(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/library/LibraryNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.library import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.library.api.LibraryEntryApi import io.github.drumber.kitsune.data.source.network.library.model.NetworkLibraryEntry import io.github.drumber.kitsune.data.source.network.toPageData import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class LibraryNetworkDataSource( private val libraryEntryApi: LibraryEntryApi ) { suspend fun getAllLibraryEntries(filter: Filter): PageData { return withContext(Dispatchers.IO) { libraryEntryApi.getAllLibraryEntries(filter.options).toPageData() } } suspend fun getLibraryEntry(id: String, filter: Filter): NetworkLibraryEntry? { return withContext(Dispatchers.IO) { libraryEntryApi.getLibraryEntry(id, filter.options).get() } } suspend fun updateLibraryEntry( id: String, libraryEntry: NetworkLibraryEntry, filter: Filter = Filter() ): NetworkLibraryEntry? { return withContext(Dispatchers.IO) { libraryEntryApi.updateLibraryEntry( id, JSONAPIDocument(libraryEntry), filter.options ).get() } } suspend fun postLibraryEntry( libraryEntry: NetworkLibraryEntry, filter: Filter = Filter() ): NetworkLibraryEntry? { return withContext(Dispatchers.IO) { libraryEntryApi.postLibraryEntry(JSONAPIDocument(libraryEntry), filter.options).get() } } suspend fun deleteLibraryEntry(id: String) { return withContext(Dispatchers.IO) { libraryEntryApi.deleteLibraryEntry(id) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/library/api/LibraryEntryApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.library.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.library.model.NetworkLibraryEntry import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.QueryMap interface LibraryEntryApi { @GET("library-entries") suspend fun getAllLibraryEntries( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("library-entries/{id}") suspend fun getLibraryEntry( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument @PATCH("library-entries/{id}") suspend fun updateLibraryEntry( @Path("id") id: String, @Body libraryEntry: JSONAPIDocument, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument @POST("library-entries") suspend fun postLibraryEntry( @Body libraryEntry: JSONAPIDocument, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument @DELETE("library-entries/{id}") suspend fun deleteLibraryEntry( @Path("id") id: String ): Response } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/library/model/NetworkLibraryEntry.kt ================================================ package io.github.drumber.kitsune.data.source.network.library.model import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.databind.annotation.JsonSerialize import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser import io.github.drumber.kitsune.util.json.NullableIntSerializer @Type("libraryEntries") data class NetworkLibraryEntry( @Id val id: String?, val updatedAt: String?, val startedAt: String?, val finishedAt: String?, val progressedAt: String?, val status: NetworkLibraryStatus?, val progress: Int?, val reconsuming: Boolean?, val reconsumeCount: Int?, val volumesOwned: Int?, /** set ratingTwenty to '-1' to serialize to 'null' */ @JsonSerialize(using = NullableIntSerializer::class) val ratingTwenty: Int?, val notes: String?, @JsonProperty("private") val privateEntry: Boolean?, val reactionSkipped: NetworkReactionSkip?, @Relationship("anime") val anime: NetworkAnime?, @Relationship("manga") val manga: NetworkManga?, @Relationship("user") val user: NetworkUser? ) { companion object { fun new( userId: String, mediaType: MediaType, mediaId: String, status: NetworkLibraryStatus? = null ) = NetworkLibraryEntry( user = NetworkUser(id = userId), status = status, anime = mediaType.takeIf { it == MediaType.Anime }?.let { NetworkAnime.empty(mediaId) }, manga = mediaType.takeIf { it == MediaType.Manga }?.let { NetworkManga.empty(mediaId) }, id = null, updatedAt = null, startedAt = null, finishedAt = null, progressedAt = null, progress = null, reconsuming = null, reconsumeCount = null, volumesOwned = null, ratingTwenty = null, notes = null, privateEntry = null, reactionSkipped = null ) fun update( id: String, startedAt: String? = null, finishedAt: String? = null, status: NetworkLibraryStatus? = null, progress: Int? = null, reconsumeCount: Int? = null, volumesOwned: Int? = null, ratingTwenty: Int? = null, notes: String? = null, isPrivate: Boolean? = null ) = NetworkLibraryEntry( id = id, updatedAt = null, startedAt = startedAt, finishedAt = finishedAt, progressedAt = null, status = status, progress = progress, reconsuming = null, reconsumeCount = reconsumeCount, volumesOwned = volumesOwned, ratingTwenty = ratingTwenty, notes = notes, privateEntry = isPrivate, reactionSkipped = null, anime = null, manga = null, user = null ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/library/model/NetworkLibraryStatus.kt ================================================ package io.github.drumber.kitsune.data.source.network.library.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkLibraryStatus { @JsonProperty("current") Current, @JsonProperty("planned") Planned, @JsonProperty("completed") Completed, @JsonProperty("on_hold") OnHold, @JsonProperty("dropped") Dropped } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/library/model/NetworkReactionSkip.kt ================================================ package io.github.drumber.kitsune.data.source.network.library.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkReactionSkip { @JsonProperty("unskipped") Unskipped, @JsonProperty("skipped") Skipped, @JsonProperty("ignored") Ignored } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/mapping/MappingNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.mapping import io.github.drumber.kitsune.data.source.network.mapping.api.MappingApi import io.github.drumber.kitsune.data.source.network.mapping.model.NetworkMapping import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class MappingNetworkDataSource( private val mappingApi: MappingApi ) { suspend fun getAnimeMappings(animeId: String, filter: Filter): List? { return withContext(Dispatchers.IO) { mappingApi.getAnimeMappings(animeId, filter.options).get() } } suspend fun getMangaMappings(mangaId: String, filter: Filter): List? { return withContext(Dispatchers.IO) { mappingApi.getMangaMappings(mangaId, filter.options).get() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/mapping/api/MappingApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.mapping.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.mapping.model.NetworkMapping import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface MappingApi { @GET("anime/{id}/mappings") suspend fun getAnimeMappings( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("manga/{id}/mappings") suspend fun getMangaMappings( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/mapping/model/NetworkMapping.kt ================================================ package io.github.drumber.kitsune.data.source.network.mapping.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type @Type("mappings") data class NetworkMapping( @Id val id: String?, val externalSite: String?, val externalId: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/AnimeNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.api.AnimeApi import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.source.network.toPageData import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class AnimeNetworkDataSource( private val animeApi: AnimeApi ) { suspend fun getAllAnime(filter: Filter): PageData { return withContext(Dispatchers.IO) { animeApi.getAllAnime(filter.options).toPageData() } } suspend fun getAnime(id: String, filter: Filter): NetworkAnime? { return withContext(Dispatchers.IO) { animeApi.getAnime(id, filter.options).get() } } suspend fun getTrending(filter: Filter): PageData { return withContext(Dispatchers.IO) { animeApi.getTrending(filter.options).toPageData() } } suspend fun getLanguages(id: String): List { return withContext(Dispatchers.IO) { animeApi.getLanguages(id) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/AnimePagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.common.Filter class AnimePagingDataSource( private val dataSource: AnimeNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getAllAnime(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/CastingNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.api.CastingApi import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkCasting import io.github.drumber.kitsune.data.source.network.toPageData import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class CastingNetworkDataSource( private val castingApi: CastingApi ) { suspend fun getAllCastings(filter: Filter): PageData { return withContext(Dispatchers.IO) { castingApi.getAllCastings(filter.options).toPageData() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/CastingPagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkCasting import io.github.drumber.kitsune.data.common.Filter class CastingPagingDataSource( private val dataSource: CastingNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getAllCastings(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/CategoryNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.media.api.CategoryApi import io.github.drumber.kitsune.data.source.network.media.model.category.NetworkCategory import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class CategoryNetworkDataSource( private val categoryApi: CategoryApi ) { suspend fun getAllCategories(filter: Filter): List? { return withContext(Dispatchers.IO) { categoryApi.getAllCategories(filter.options).get() } } suspend fun getCategory(id: String, filter: Filter): NetworkCategory? { return withContext(Dispatchers.IO) { categoryApi.getCategory(id, filter.options).get() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/ChapterNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.api.ChapterApi import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkChapter import io.github.drumber.kitsune.data.source.network.toPageData import io.github.drumber.kitsune.data.common.Filter class ChapterNetworkDataSource( private val chapterApi: ChapterApi ) { suspend fun getAllChapters(filter: Filter): PageData { return chapterApi.getAllChapters(filter.options).toPageData() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/ChapterPagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkChapter import io.github.drumber.kitsune.data.common.Filter class ChapterPagingDataSource( private val dataSource: ChapterNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getAllChapters(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/EpisodeNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.api.EpisodeApi import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkEpisode import io.github.drumber.kitsune.data.source.network.toPageData import io.github.drumber.kitsune.data.common.Filter class EpisodeNetworkDataSource( private val episodeApi: EpisodeApi ) { suspend fun getAllEpisodes(filter: Filter): PageData { return episodeApi.getAllEpisodes(filter.options).toPageData() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/EpisodePagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkEpisode import io.github.drumber.kitsune.data.common.Filter class EpisodePagingDataSource( private val dataSource: EpisodeNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getAllEpisodes(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/MangaNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.api.MangaApi import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import io.github.drumber.kitsune.data.source.network.toPageData import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class MangaNetworkDataSource( private val mangaApi: MangaApi ) { suspend fun getAllManga(filter: Filter): PageData { return withContext(Dispatchers.IO) { mangaApi.getAllManga(filter.options).toPageData() } } suspend fun getManga(id: String, filter: Filter): NetworkManga? { return withContext(Dispatchers.IO) { mangaApi.getManga(id, filter.options).get() } } suspend fun getTrending(filter: Filter): PageData { return withContext(Dispatchers.IO) { mangaApi.getTrending(filter.options).toPageData() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/MangaPagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import io.github.drumber.kitsune.data.common.Filter class MangaPagingDataSource( private val dataSource: MangaNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getAllManga(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/TrendingAnimePagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.common.Filter class TrendingAnimePagingDataSource( private val dataSource: AnimeNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getTrending(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/TrendingMangaPagingDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.media import io.github.drumber.kitsune.data.source.network.BasePagingDataSource import io.github.drumber.kitsune.data.source.network.PageData import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import io.github.drumber.kitsune.data.common.Filter class TrendingMangaPagingDataSource( private val dataSource: MangaNetworkDataSource, private val filter: Filter ) : BasePagingDataSource() { override suspend fun requestPage(pageOffset: Int): PageData { return dataSource.getTrending(filter.pageOffset(pageOffset)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/api/AnimeApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface AnimeApi { @GET("anime") suspend fun getAllAnime( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("anime/{id}") suspend fun getAnime( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument @GET("trending/anime") suspend fun getTrending( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> // Will probably be replaced by origin_languages attribute in anime and manga // see: https://github.com/hummingbird-me/kitsu-server/commit/e730ef2e0482d37e7252496c9e937c3e1164bf08 @GET("anime/{id}/_languages") suspend fun getLanguages( @Path("id") id: String ): List } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/api/CastingApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkCasting import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface CastingApi { @GET("castings") suspend fun getAllCastings( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("castings/{id}") suspend fun getCasting( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/api/CategoryApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.media.model.category.NetworkCategory import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface CategoryApi { @GET("categories") suspend fun getAllCategories( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("categories/{id}") suspend fun getCategory( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/api/ChapterApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkChapter import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface ChapterApi { @GET("chapters") suspend fun getAllChapters( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("chapters/{id}") suspend fun getChapter( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/api/EpisodeApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkEpisode import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface EpisodeApi { @GET("episodes") suspend fun getAllEpisodes( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("episodes/{id}") suspend fun getEpisode( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/api/MangaApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import retrofit2.http.GET import retrofit2.http.Path import retrofit2.http.QueryMap interface MangaApi { @GET("manga") suspend fun getAllManga( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("manga/{id}") suspend fun getManga( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument @GET("trending/manga") suspend fun getTrending( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/NetworkAnime.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.source.network.media.model.category.NetworkCategory import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkAnimeProduction import io.github.drumber.kitsune.data.source.network.media.model.relationship.NetworkMediaRelationship import io.github.drumber.kitsune.data.source.network.media.model.streamer.NetworkStreamingLink @Type("anime") data class NetworkAnime( @Id override val id: String = "", override val slug: String?, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val abbreviatedTitles: List?, override val averageRating: String?, override val ratingFrequencies: NetworkRatingFrequencies?, override val userCount: Int?, override val favoritesCount: Int?, override val popularityRank: Int?, override val ratingRank: Int?, override val startDate: String?, override val endDate: String?, override val nextRelease: String?, override val tba: String?, override val status: NetworkReleaseStatus?, override val ageRating: AgeRating?, override val ageRatingGuide: String?, override val nsfw: Boolean?, override val posterImage: Image?, override val coverImage: Image?, override val totalLength: Int?, val episodeCount: Int?, val episodeLength: Int?, val youtubeVideoId: String?, val subtype: NetworkAnimeSubtype?, @Relationship("categories") override val categories: List?, @Relationship("animeProductions") val animeProduction: List?, @Relationship("streamingLinks") val streamingLinks: List?, @Relationship("mediaRelationships") override val mediaRelationships: List? ) : NetworkMedia { companion object { fun empty(id: String) = NetworkAnime( id = id, slug = null, description = null, titles = null, canonicalTitle = null, abbreviatedTitles = null, averageRating = null, ratingFrequencies = null, userCount = null, favoritesCount = null, popularityRank = null, ratingRank = null, startDate = null, endDate = null, nextRelease = null, tba = null, status = null, ageRating = null, ageRatingGuide = null, nsfw = null, posterImage = null, coverImage = null, totalLength = null, episodeCount = null, episodeLength = null, youtubeVideoId = null, subtype = null, categories = null, animeProduction = null, streamingLinks = null, mediaRelationships = null ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/NetworkAnimeSubtype.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkAnimeSubtype { @JsonProperty("ONA") ONA, @JsonProperty("OVA") OVA, @JsonProperty("TV") TV, @JsonProperty("movie") Movie, @JsonProperty("music") Music, @JsonProperty("special") Special } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/NetworkManga.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.source.network.media.model.category.NetworkCategory import io.github.drumber.kitsune.data.source.network.media.model.relationship.NetworkMediaRelationship @Type("manga") data class NetworkManga( @Id override val id: String = "", override val slug: String?, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val abbreviatedTitles: List?, override val averageRating: String?, override val ratingFrequencies: NetworkRatingFrequencies?, override val userCount: Int?, override val favoritesCount: Int?, override val popularityRank: Int?, override val ratingRank: Int?, override val startDate: String?, override val endDate: String?, override val nextRelease: String?, override val tba: String?, override val status: NetworkReleaseStatus?, override val ageRating: AgeRating?, override val ageRatingGuide: String?, override val nsfw: Boolean?, override val posterImage: Image?, override val coverImage: Image?, override val totalLength: Int?, val chapterCount: Int?, val volumeCount: Int?, val subtype: NetworkMangaSubtype?, val serialization: String?, @Relationship("categories") override val categories: List?, @Relationship("mediaRelationships") override val mediaRelationships: List? ) : NetworkMedia { companion object { fun empty(id: String) = NetworkManga( id = id, slug = null, description = null, titles = null, canonicalTitle = null, abbreviatedTitles = null, averageRating = null, ratingFrequencies = null, userCount = null, favoritesCount = null, popularityRank = null, ratingRank = null, startDate = null, endDate = null, nextRelease = null, tba = null, status = null, ageRating = null, ageRatingGuide = null, nsfw = null, posterImage = null, coverImage = null, totalLength = null, chapterCount = null, volumeCount = null, subtype = null, serialization = null, categories = null, mediaRelationships = null ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/NetworkMangaSubtype.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkMangaSubtype { @JsonProperty("doujin") Doujin, @JsonProperty("manga") Manga, @JsonProperty("manhua") Manhua, @JsonProperty("manhwa") Manhwa, @JsonProperty("novel") Novel, @JsonProperty("oel") Oel, @JsonProperty("oneshot") Oneshot } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/NetworkMedia.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.media.AgeRating import io.github.drumber.kitsune.data.source.network.media.model.category.NetworkCategory import io.github.drumber.kitsune.data.source.network.media.model.relationship.NetworkMediaRelationship import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavoriteItem sealed interface NetworkMedia : NetworkFavoriteItem { val id: String val slug: String? val description: String? val titles: Titles? val canonicalTitle: String? val abbreviatedTitles: List? val averageRating: String? val ratingFrequencies: NetworkRatingFrequencies? val userCount: Int? val favoritesCount: Int? val popularityRank: Int? val ratingRank: Int? val startDate: String? val endDate: String? val nextRelease: String? val tba: String? val status: NetworkReleaseStatus? val ageRating: AgeRating? val ageRatingGuide: String? val nsfw: Boolean? val posterImage: Image? val coverImage: Image? val totalLength: Int? // Relationships val categories: List? val mediaRelationships: List? } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/NetworkRatingFrequencies.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model import com.fasterxml.jackson.annotation.JsonProperty data class NetworkRatingFrequencies( @JsonProperty("2") val r2: String?, @JsonProperty("3") val r3: String?, @JsonProperty("4") val r4: String?, @JsonProperty("5") val r5: String?, @JsonProperty("6") val r6: String?, @JsonProperty("7") val r7: String?, @JsonProperty("8") val r8: String?, @JsonProperty("9") val r9: String?, @JsonProperty("10") val r10: String?, @JsonProperty("11") val r11: String?, @JsonProperty("12") val r12: String?, @JsonProperty("13") val r13: String?, @JsonProperty("14") val r14: String?, @JsonProperty("15") val r15: String?, @JsonProperty("16") val r16: String?, @JsonProperty("17") val r17: String?, @JsonProperty("18") val r18: String?, @JsonProperty("19") val r19: String?, @JsonProperty("20") val r20: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/NetworkReleaseStatus.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkReleaseStatus { @JsonProperty("current") Current, @JsonProperty("finished") Finished, @JsonProperty("tba") TBA, @JsonProperty("unreleased") Unreleased, @JsonProperty("upcoming") Upcoming } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/category/NetworkCategory.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.category import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type @Type("categories") data class NetworkCategory( @Id val id: String?, val slug: String?, val title: String?, val description: String?, val nsfw: Boolean?, val totalMediaCount: Int?, val childCount: Int? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/production/NetworkAnimeProduction.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.production import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type @Type("animeProductions") data class NetworkAnimeProduction( @Id val id: String?, val role: NetworkAnimeProductionRole?, @Relationship("producer") val producer: NetworkProducer? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/production/NetworkAnimeProductionRole.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.production import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkAnimeProductionRole { @JsonProperty("licensor") Licensor, @JsonProperty("producer") Producer, @JsonProperty("studio") Studio } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/production/NetworkCasting.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.production import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter @Type("castings") data class NetworkCasting( @Id val id: String?, val role: String?, val voiceActor: Boolean?, val featured: Boolean?, val language: String?, @Relationship("character") val character: NetworkCharacter?, @Relationship("person") val person: NetworkPerson? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/production/NetworkPerson.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.production import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.Image @Type("people") data class NetworkPerson( @Id val id: String?, val name: String?, val description: String?, val image: Image? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/production/NetworkProducer.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.production import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type @Type("producers") data class NetworkProducer( @Id val id: String?, val slug: String?, val name: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/relationship/NetworkMediaRelationship.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.relationship import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.source.network.media.model.NetworkMedia @Type("mediaRelationships") data class NetworkMediaRelationship( @Id val id: String?, val role: NetworkMediaRelationshipRole?, @Relationship("destination") val media: NetworkMedia? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/relationship/NetworkMediaRelationshipRole.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.relationship import com.fasterxml.jackson.annotation.JsonProperty /** * Media relationship roles, sort order from * https://github.com/hummingbird-me/kitsu-server/blob/the-future/app/models/media_relationship.rb */ enum class NetworkMediaRelationshipRole { @JsonProperty("sequel") Sequel, @JsonProperty("prequel") Prequel, @JsonProperty("alternative_setting") AlternativeSetting, @JsonProperty("alternative_version") AlternativeVersion, @JsonProperty("side_story") SideStory, @JsonProperty("parent_story") ParentStory, @JsonProperty("summary") Summary, @JsonProperty("full_story") FullStory, @JsonProperty("spinoff") Spinoff, @JsonProperty("adaptation") Adaptation, @JsonProperty("character") Character, @JsonProperty("other") Other } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/streamer/NetworkStreamer.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.streamer import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type @Type("streamers") data class NetworkStreamer( @Id val id: String?, val siteName: String?, ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/streamer/NetworkStreamingLink.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.streamer import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type @Type("streamingLinks") data class NetworkStreamingLink( @Id val id: String?, val url: String?, val subs: List?, val dubs: List?, @Relationship("streamer") val streamer: NetworkStreamer? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/unit/NetworkChapter.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.unit import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles @Type("chapters") data class NetworkChapter( @Id override val id: String?, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val number: Int?, val volumeNumber: Int?, override val length: String?, override val thumbnail: Image?, val published: String? ) : NetworkMediaUnit ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/unit/NetworkEpisode.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.unit import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles @Type("episodes") data class NetworkEpisode( @Id override val id: String?, override val description: String?, override val titles: Titles?, override val canonicalTitle: String?, override val number: Int?, val seasonNumber: Int?, val relativeNumber: Int?, override val length: String?, val airdate: String?, override val thumbnail: Image? ) : NetworkMediaUnit ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/media/model/unit/NetworkMediaUnit.kt ================================================ package io.github.drumber.kitsune.data.source.network.media.model.unit import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.Titles sealed interface NetworkMediaUnit { val id: String? val description: String? val titles: Titles? val canonicalTitle: String? val number: Int? val length: String? val thumbnail: Image? } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/FavoriteNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.user import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.user.api.FavoriteApi import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavorite import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class FavoriteNetworkDataSource( private val favoriteApi: FavoriteApi ) { suspend fun getAllFavorites(filter: Filter): List? { return withContext(Dispatchers.IO) { favoriteApi.getAllFavorites(filter.options).get() } } suspend fun createFavorite(favorite: NetworkFavorite): NetworkFavorite? { return withContext(Dispatchers.IO) { favoriteApi.postFavorite(JSONAPIDocument(favorite)).get() } } suspend fun deleteFavorite(id: String): Boolean { return withContext(Dispatchers.IO) { favoriteApi.deleteFavorite(id).isSuccessful } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/ProfileLinkNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.user import io.github.drumber.kitsune.data.source.network.user.api.ProfileLinkApi import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLinkSite import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class ProfileLinkNetworkDataSource( private val profileLinkApi: ProfileLinkApi ) { suspend fun getAllProfileLinkSites(filter: Filter): List? { return withContext(Dispatchers.IO) { profileLinkApi.getAllProfileLinkSites(filter.options).get() } } suspend fun createProfileLink(profileLink: NetworkProfileLink): NetworkProfileLink? { return withContext(Dispatchers.IO) { profileLinkApi.createProfileLink(profileLink).get() } } suspend fun updateProfileLink(id: String, profileLink: NetworkProfileLink): NetworkProfileLink? { return withContext(Dispatchers.IO) { profileLinkApi.updateProfileLink(id, profileLink).get() } } suspend fun deleteProfileLink(id: String): Boolean { return withContext(Dispatchers.IO) { profileLinkApi.deleteProfileLink(id).isSuccessful } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/UserNetworkDataSource.kt ================================================ package io.github.drumber.kitsune.data.source.network.user import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.user.api.UserApi import io.github.drumber.kitsune.data.source.network.user.api.UserImageUploadApi import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser import io.github.drumber.kitsune.data.source.network.user.model.NetworkUserImageUpload import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.common.Filter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class UserNetworkDataSource( private val userApi: UserApi, private val imageUploadApi: UserImageUploadApi ) { suspend fun getSelf(baseFilter: Filter): NetworkUser? { val filter = baseFilter.copy() .filter("self", "true") return withContext(Dispatchers.IO) { userApi.getAllUsers(filter.options).get()?.firstOrNull() } } suspend fun getUser(userId: String, filter: Filter): NetworkUser? { return withContext(Dispatchers.IO) { userApi.getUser(userId, filter.options).get() } } suspend fun updateUser(userId: String, user: NetworkUser): NetworkUser? { return withContext(Dispatchers.IO) { userApi.updateUser(userId, JSONAPIDocument(user)).get() } } suspend fun updateUserImage(userId: String, user: NetworkUserImageUpload): Boolean { return withContext(Dispatchers.IO) { imageUploadApi.updateUserImage(userId, JSONAPIDocument(user)).isSuccessful } } suspend fun getProfileLinksForUser(userId: String, filter: Filter): List? { return withContext(Dispatchers.IO) { userApi.getProfileLinksForUser(userId, filter.options).get() } } suspend fun deleteWaifuRelationship(userId: String): Boolean { return withContext(Dispatchers.IO) { userApi.deleteWaifuRelationship(userId).isSuccessful } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/api/FavoriteApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavorite import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.QueryMap interface FavoriteApi { @GET("favorites") suspend fun getAllFavorites( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @POST("favorites") suspend fun postFavorite( @Body favorite: JSONAPIDocument ): JSONAPIDocument @DELETE("favorites/{id}") suspend fun deleteFavorite( @Path("id") id: String ): Response } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/api/ProfileLinkApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLinkSite import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.QueryMap interface ProfileLinkApi { @GET("profile-link-sites") suspend fun getAllProfileLinkSites( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @POST("profile-links") suspend fun createProfileLink( @Body profileLink: NetworkProfileLink ): JSONAPIDocument @PATCH("profile-links/{id}") suspend fun updateProfileLink( @Path("id") id: String, @Body profileLink: NetworkProfileLink ): JSONAPIDocument @DELETE("profile-links/{id}") suspend fun deleteProfileLink( @Path("id") id: String ): Response } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/api/UserApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.PATCH import retrofit2.http.Path import retrofit2.http.QueryMap interface UserApi { @GET("users") suspend fun getAllUsers( @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> @GET("users/{id}") suspend fun getUser( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument @PATCH("users/{id}") suspend fun updateUser( @Path("id") id: String, @Body user: JSONAPIDocument ): JSONAPIDocument @DELETE("users/{id}/relationships/waifu") suspend fun deleteWaifuRelationship( @Path("id") id: String ): Response @GET("users/{id}/profile-links") suspend fun getProfileLinksForUser( @Path("id") id: String, @QueryMap filter: Map = emptyMap() ): JSONAPIDocument> } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/api/UserImageUploadApi.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.api import com.github.jasminb.jsonapi.JSONAPIDocument import io.github.drumber.kitsune.data.source.network.user.model.NetworkUserImageUpload import retrofit2.Response import retrofit2.http.Body import retrofit2.http.PATCH import retrofit2.http.Path interface UserImageUploadApi { @PATCH("users/{id}") suspend fun updateUserImage( @Path("id") id: String, @Body user: JSONAPIDocument ): Response } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/NetworkFavorite.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type @Type("favorites") data class NetworkFavorite( @Id val id: String? = null, val favRank: Int? = null, @Relationship("item") val item: NetworkFavoriteItem? = null, @Relationship("user") val user: NetworkUser? = null ) interface NetworkFavoriteItem ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/NetworkRatingSystemPreference.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkRatingSystemPreference { // 0.5, 1...10 @JsonProperty("advanced") Advanced, // 0.5, 1...5 @JsonProperty("regular") Regular, // :(, :|, :), :D @JsonProperty("simple") Simple } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/NetworkSfwFilterPreference.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkSfwFilterPreference { @JsonProperty("sfw") SFW, @JsonProperty("nsfw_sometimes") NSFW_SOMETIMES, @JsonProperty("nsfw_everywhere") NSFW_EVERYWHERE } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/NetworkTitleLanguagePreference.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkTitleLanguagePreference { @JsonProperty("canonical") Canonical, @JsonProperty("romanized") Romanized, @JsonProperty("english") English } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/NetworkUser.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.common.Image import io.github.drumber.kitsune.data.common.user.UserThemePreference import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkUserStats @Type("users") data class NetworkUser( @Id val id: String?, val createdAt: String? = null, val updatedAt: String? = null, val name: String? = null, val slug: String? = null, val email: String? = null, val title: String? = null, val avatar: Image? = null, val coverImage: Image? = null, val about: String? = null, val location: String? = null, val gender: String? = null, val birthday: String? = null, val waifuOrHusbando: String? = null, val followersCount: Int? = null, val followingCount: Int? = null, val commentsCount: Int? = null, val favoritesCount: Int? = null, val likesGivenCount: Int? = null, val reviewsCount: Int? = null, val likesReceivedCount: Int? = null, val postsCount: Int? = null, val ratingsCount: Int? = null, val mediaReactionsCount: Int? = null, val country: String? = null, val language: String? = null, val timeZone: String? = null, val theme: UserThemePreference? = null, val sfwFilter: Boolean? = null, val ratingSystem: NetworkRatingSystemPreference? = null, val shareToGlobal: Boolean? = null, val sfwFilterPreference: NetworkSfwFilterPreference? = null, val titleLanguagePreference: NetworkTitleLanguagePreference? = null, val profileCompleted: Boolean? = null, val feedCompleted: Boolean? = null, val proTier: String? = null, val proExpiresAt: String? = null, val aoPro: String? = null, val facebookId: String? = null, val confirmed: Boolean? = null, val status: String? = null, val hasPassword: Boolean? = null, val subscribedToNewsletter: Boolean? = null, @Relationship("stats") val stats: List? = null, @Relationship("favorites") val favorites: List? = null, @Relationship("waifu") val waifu: NetworkCharacter? = null, @Relationship("profileLinks") val profileLinks: List? = null, ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/NetworkUserImageUpload.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type @Type("users") data class NetworkUserImageUpload( @Id val id: String?, /** Avatar image as Base64 encoded string */ val avatar: String? = null, /** Cover image as Base64 encoded string */ val coverImage: String? = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/profilelinks/NetworkProfileLink.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model.profilelinks import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Relationship import com.github.jasminb.jsonapi.annotations.Type import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser @Type("profileLinks") data class NetworkProfileLink( @Id val id: String?, val url: String?, @Relationship("profileLinkSite") val profileLinkSite: NetworkProfileLinkSite?, @Relationship("user") val user: NetworkUser? ) { companion object { fun empty() = NetworkProfileLink(null, null, null, null) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/profilelinks/NetworkProfileLinkSite.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model.profilelinks import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type @Type("profileLinkSites") data class NetworkProfileLinkSite( @Id val id: String?, val name: String? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/stats/NetworkAmountConsumedPercentiles.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model.stats data class NetworkAmountConsumedPercentiles( val media: Float?, val units: Float?, val time: Float? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/stats/NetworkUserStats.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model.stats import com.fasterxml.jackson.annotation.JsonTypeInfo import com.github.jasminb.jsonapi.annotations.Id import com.github.jasminb.jsonapi.annotations.Type @Type("stats") data class NetworkUserStats( @Id val id: String?, val kind: NetworkUserStatsKind?, @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXTERNAL_PROPERTY, property = "kind", visible = true ) val statsData: NetworkUserStatsData? ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/stats/NetworkUserStatsData.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model.stats import com.fasterxml.jackson.annotation.JsonSubTypes @JsonSubTypes( JsonSubTypes.Type( value = NetworkUserStatsData.NetworkCategoryBreakdownData::class, names = ["anime-category-breakdown", "manga-category-breakdown"] ), JsonSubTypes.Type( value = NetworkUserStatsData.NetworkAmountConsumedData::class, names = ["anime-amount-consumed", "manga-amount-consumed"] ) ) sealed class NetworkUserStatsData { data class NetworkCategoryBreakdownData( val total: Int?, val categories: Map? ) : NetworkUserStatsData() data class NetworkAmountConsumedData( val time: Long?, val media: Int?, val units: Int?, val completed: Int?, val percentiles: NetworkAmountConsumedPercentiles?, val averageDiffs: NetworkAmountConsumedPercentiles? ) : NetworkUserStatsData() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/source/network/user/model/stats/NetworkUserStatsKind.kt ================================================ package io.github.drumber.kitsune.data.source.network.user.model.stats import com.fasterxml.jackson.annotation.JsonProperty enum class NetworkUserStatsKind { @JsonProperty("anime-activity-history") AnimeActivityHistory, @JsonProperty("anime-amount-consumed") AnimeAmountConsumed, @JsonProperty("anime-category-breakdown") AnimeCategoryBreakdown, @JsonProperty("anime-favorite-year") AnimeFavoriteYear, @JsonProperty("manga-activity-history") MangaActivityHistory, @JsonProperty("manga-amount-consumed") MangaAmountConsumed, @JsonProperty("manga-category-breakdown") MangaCategoryBreakdown, @JsonProperty("manga-favorite-year") MangaFavoriteYear } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/data/utils/InvalidatingPagingSourceFactory.kt ================================================ package io.github.drumber.kitsune.data.utils import androidx.paging.PagingSource import java.util.concurrent.locks.ReentrantLock import kotlin.concurrent.withLock /** * Modified version of [androidx.paging.InvalidatingPagingSourceFactory] accepting a custom input property. */ class InvalidatingPagingSourceFactory( private val pagingSourceFactory: (input: Input) -> PagingSource ) { private val lock = ReentrantLock() private var pagingSources: List> = emptyList() fun createPagingSource(input: Input): PagingSource { return pagingSourceFactory(input).also { lock.withLock { pagingSources = pagingSources + it } } } fun invalidate() { val previousList = lock.withLock { pagingSources.also { pagingSources = emptyList() } } for (pagingSource in previousList) { if (!pagingSource.invalid) { pagingSource.invalidate() } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/di/AppModule.kt ================================================ package io.github.drumber.kitsune.di val appModule = listOf( networkModule, viewModelModule, dataModule, databaseModule, domainModule ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/di/DataModule.kt ================================================ package io.github.drumber.kitsune.di import com.fasterxml.jackson.databind.ObjectMapper import io.github.drumber.kitsune.constants.GitHub import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.repository.AlgoliaKeyRepository import io.github.drumber.kitsune.data.repository.AnimeRepository import io.github.drumber.kitsune.data.repository.AppUpdateRepository import io.github.drumber.kitsune.data.repository.CastingRepository import io.github.drumber.kitsune.data.repository.CategoryRepository import io.github.drumber.kitsune.data.repository.CharacterRepository import io.github.drumber.kitsune.data.repository.FavoriteRepository import io.github.drumber.kitsune.data.repository.LibraryChangeListener import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.data.repository.MangaRepository import io.github.drumber.kitsune.data.repository.MappingRepository import io.github.drumber.kitsune.data.repository.MediaUnitRepository import io.github.drumber.kitsune.data.repository.ProfileLinkRepository import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.data.repository.WidgetLibraryChangeListener import io.github.drumber.kitsune.data.source.local.auth.AccessTokenLocalDataSource import io.github.drumber.kitsune.data.source.local.auth.AccessTokenPreference import io.github.drumber.kitsune.data.source.local.library.LibraryLocalDataSource import io.github.drumber.kitsune.data.source.local.user.UserLocalDataSource import io.github.drumber.kitsune.data.source.local.user.UserPreferences import io.github.drumber.kitsune.data.source.network.algolia.AlgoliaKeyNetworkDataSource import io.github.drumber.kitsune.data.source.network.algolia.api.AlgoliaKeyApi import io.github.drumber.kitsune.data.source.network.appupdate.AppReleaseNetworkDataSource import io.github.drumber.kitsune.data.source.network.appupdate.api.GitHubApi import io.github.drumber.kitsune.data.source.network.auth.AccessTokenNetworkDataSource import io.github.drumber.kitsune.data.source.network.auth.api.AuthenticationApi import io.github.drumber.kitsune.data.source.network.character.CharacterNetworkDataSource import io.github.drumber.kitsune.data.source.network.character.api.CharacterApi import io.github.drumber.kitsune.data.source.network.character.model.NetworkCharacter import io.github.drumber.kitsune.data.source.network.character.model.NetworkMediaCharacter import io.github.drumber.kitsune.data.source.network.library.LibraryNetworkDataSource import io.github.drumber.kitsune.data.source.network.library.api.LibraryEntryApi import io.github.drumber.kitsune.data.source.network.library.model.NetworkLibraryEntry import io.github.drumber.kitsune.data.source.network.mapping.MappingNetworkDataSource import io.github.drumber.kitsune.data.source.network.mapping.api.MappingApi import io.github.drumber.kitsune.data.source.network.mapping.model.NetworkMapping import io.github.drumber.kitsune.data.source.network.media.AnimeNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.CastingNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.CategoryNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.ChapterNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.EpisodeNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.MangaNetworkDataSource import io.github.drumber.kitsune.data.source.network.media.api.AnimeApi import io.github.drumber.kitsune.data.source.network.media.api.CastingApi import io.github.drumber.kitsune.data.source.network.media.api.CategoryApi import io.github.drumber.kitsune.data.source.network.media.api.ChapterApi import io.github.drumber.kitsune.data.source.network.media.api.EpisodeApi import io.github.drumber.kitsune.data.source.network.media.api.MangaApi import io.github.drumber.kitsune.data.source.network.media.model.NetworkAnime import io.github.drumber.kitsune.data.source.network.media.model.NetworkManga import io.github.drumber.kitsune.data.source.network.media.model.category.NetworkCategory import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkAnimeProduction import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkCasting import io.github.drumber.kitsune.data.source.network.media.model.production.NetworkProducer import io.github.drumber.kitsune.data.source.network.media.model.relationship.NetworkMediaRelationship import io.github.drumber.kitsune.data.source.network.media.model.streamer.NetworkStreamer import io.github.drumber.kitsune.data.source.network.media.model.streamer.NetworkStreamingLink import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkChapter import io.github.drumber.kitsune.data.source.network.media.model.unit.NetworkEpisode import io.github.drumber.kitsune.data.source.network.user.FavoriteNetworkDataSource import io.github.drumber.kitsune.data.source.network.user.ProfileLinkNetworkDataSource import io.github.drumber.kitsune.data.source.network.user.UserNetworkDataSource import io.github.drumber.kitsune.data.source.network.user.api.FavoriteApi import io.github.drumber.kitsune.data.source.network.user.api.ProfileLinkApi import io.github.drumber.kitsune.data.source.network.user.api.UserApi import io.github.drumber.kitsune.data.source.network.user.api.UserImageUploadApi import io.github.drumber.kitsune.data.source.network.user.model.NetworkFavorite import io.github.drumber.kitsune.data.source.network.user.model.NetworkUser import io.github.drumber.kitsune.data.source.network.user.model.NetworkUserImageUpload import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLink import io.github.drumber.kitsune.data.source.network.user.model.profilelinks.NetworkProfileLinkSite import io.github.drumber.kitsune.data.source.network.user.model.stats.NetworkUserStats import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import org.koin.android.ext.koin.androidApplication import org.koin.android.ext.koin.androidContext import org.koin.core.qualifier.named import org.koin.dsl.module val dataModule = module { // Auth factory { createAuthService(get()) } single { AccessTokenNetworkDataSource(get()) } single { AccessTokenPreference(androidContext(), get()) } single { AccessTokenRepository(get(), get()) } // User factory { createService( get(), get(), NetworkUser::class.java, NetworkUserStats::class.java, NetworkFavorite::class.java, NetworkAnime::class.java, NetworkManga::class.java, NetworkCharacter::class.java ) } factory { createService( get(), get(), NetworkUserImageUpload::class.java ) } single { UserNetworkDataSource(get(), get()) } single { UserPreferences(androidContext(), get()) } single { UserRepository(get(), get(), CoroutineScope(SupervisorJob() + Dispatchers.Default)) } // Algolia factory { createService(get(), get()) } single { AlgoliaKeyNetworkDataSource(get()) } single { AlgoliaKeyRepository(get()) } // ProfileLinks factory { createService( get(), get(), NetworkProfileLink::class.java, NetworkProfileLinkSite::class.java, NetworkUser::class.java ) } single { ProfileLinkNetworkDataSource(get()) } single { ProfileLinkRepository(get()) } // Favorite factory { createService( get(), get(), NetworkFavorite::class.java, NetworkAnime::class.java, NetworkManga::class.java, NetworkUser::class.java ) } single { FavoriteNetworkDataSource(get()) } single { FavoriteRepository(get()) } // Anime factory { createService( get(), get(), NetworkAnime::class.java, NetworkManga::class.java, NetworkCategory::class.java, NetworkAnimeProduction::class.java, NetworkProducer::class.java, NetworkStreamingLink::class.java, NetworkStreamer::class.java, NetworkMediaRelationship::class.java ) } single { AnimeNetworkDataSource(get()) } single { AnimeRepository(get()) } // Manga factory { createService( get(), get(), NetworkManga::class.java, NetworkAnime::class.java, NetworkCategory::class.java, NetworkMediaRelationship::class.java ) } single { MangaNetworkDataSource(get()) } single { MangaRepository(get()) } // Media Unit factory { createService(get(), get(), NetworkEpisode::class.java) } factory { createService(get(), get(), NetworkChapter::class.java) } single { EpisodeNetworkDataSource(get()) } single { ChapterNetworkDataSource(get()) } single { MediaUnitRepository(get(), get()) } // Casting factory { createService( get(), get(), NetworkCasting::class.java, NetworkCharacter::class.java ) } single { CastingNetworkDataSource(get()) } single { CastingRepository(get()) } // Category factory { createService(get(), get(), NetworkCategory::class.java) } single { CategoryNetworkDataSource(get()) } single { CategoryRepository(get()) } // Character factory { createService( get(), get(), NetworkCharacter::class.java, NetworkMediaCharacter::class.java, NetworkAnime::class.java, NetworkManga::class.java ) } single { CharacterNetworkDataSource(get()) } single { CharacterRepository(get()) } // Mapping factory { createService( get(), get(), NetworkMapping::class.java ) } single { MappingNetworkDataSource(get()) } single { MappingRepository(get()) } // Library Entry factory { createService( get(), get(), NetworkLibraryEntry::class.java, NetworkAnime::class.java, NetworkManga::class.java ) } single { LibraryNetworkDataSource(get()) } single { LibraryLocalDataSource(get()) } single { WidgetLibraryChangeListener(androidApplication(), get()) } single { LibraryRepository( get(), get(), get(), CoroutineScope(SupervisorJob() + Dispatchers.Default) ) } // App Update factory { createService( get(named("unauthenticated")), get(), GitHub.API_URL ) } single { AppReleaseNetworkDataSource(get()) } single { AppUpdateRepository(get()) } } private fun createAuthService(objectMapper: ObjectMapper) = createService( createHttpClientBuilder(addLoggingInterceptor = false).build(), objectMapper, Kitsu.OAUTH_URL ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/di/DatabaseModule.kt ================================================ package io.github.drumber.kitsune.di import io.github.drumber.kitsune.data.source.local.LocalDatabase import org.koin.android.ext.koin.androidApplication import org.koin.dsl.module val databaseModule = module { single { LocalDatabase.createLocalDatabase(androidApplication()) } factory { get().libraryEntryDao() } factory { get().libraryEntryModificationDao() } factory { get().libraryEntryWithModificationDao() } factory { get().remoteKeyDao() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/di/DomainModule.kt ================================================ package io.github.drumber.kitsune.di import io.github.drumber.kitsune.domain.auth.IsUserLoggedInUseCase import io.github.drumber.kitsune.domain.auth.LogInUserUseCase import io.github.drumber.kitsune.domain.auth.LogOutUserUseCase import io.github.drumber.kitsune.domain.auth.RefreshAccessTokenIfExpiredUseCase import io.github.drumber.kitsune.domain.auth.RefreshAccessTokenUseCase import io.github.drumber.kitsune.domain.library.FetchLibraryEntriesForWidgetUseCase import io.github.drumber.kitsune.domain.library.GetLibraryEntriesWithModificationsPagerUseCase import io.github.drumber.kitsune.domain.library.SearchLibraryEntriesWithLocalModificationsPagerUseCase import io.github.drumber.kitsune.domain.library.SynchronizeLocalLibraryModificationsUseCase import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryProgressUseCase import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryRatingUseCase import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryUseCase import io.github.drumber.kitsune.domain.user.GetLocalUserIdUseCase import io.github.drumber.kitsune.domain.user.UpdateLocalUserUseCase import io.github.drumber.kitsune.domain.work.UpdateLibraryWidgetUseCase import org.koin.dsl.module val domainModule = module { // Auth factory { IsUserLoggedInUseCase(get()) } factory { LogInUserUseCase(get(), get(), get()) } factory { LogOutUserUseCase(get(), get()) } factory { RefreshAccessTokenIfExpiredUseCase(get(), get()) } factory { RefreshAccessTokenUseCase(get(), get(), get()) } // User factory { GetLocalUserIdUseCase(get()) } factory { UpdateLocalUserUseCase(get(), get(), get()) } // Library factory { GetLibraryEntriesWithModificationsPagerUseCase(get()) } factory { SearchLibraryEntriesWithLocalModificationsPagerUseCase(get()) } factory { SynchronizeLocalLibraryModificationsUseCase(get(), get()) } factory { UpdateLibraryEntryProgressUseCase(get()) } factory { UpdateLibraryEntryRatingUseCase(get()) } factory { UpdateLibraryEntryUseCase(get()) } factory { FetchLibraryEntriesForWidgetUseCase(get(), get()) } // Work factory { UpdateLibraryWidgetUseCase() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/di/NetworkModule.kt ================================================ package io.github.drumber.kitsune.di import android.content.Context import android.os.Parcelable import com.algolia.search.model.filter.Filter import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.MapperFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.module.kotlin.jacksonMapperBuilder import com.github.jasminb.jsonapi.ResourceConverter import com.github.jasminb.jsonapi.retrofit.JSONAPIConverterFactory import io.github.drumber.kitsune.BuildConfig import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.util.json.AlgoliaFacetValueDeserializer import io.github.drumber.kitsune.util.json.AlgoliaNumericValueDeserializer import io.github.drumber.kitsune.util.json.IgnoreParcelablePropertyMixin import io.github.drumber.kitsune.util.network.AuthenticationInterceptor import io.github.drumber.kitsune.util.network.AuthenticationInterceptorImpl import io.github.drumber.kitsune.util.network.UserAgentInterceptor import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.logging.HttpLoggingInterceptor import org.koin.core.qualifier.named import org.koin.dsl.module import retrofit2.Retrofit import retrofit2.converter.jackson.JacksonConverterFactory import java.io.File import java.util.concurrent.TimeUnit val networkModule = module { single { createHttpClient(get(), get()) } single(named("unauthenticated")) { createHttpClientBuilder().build() } single(named("images")) { createHttpClientBuilder(false).build() } single { createObjectMapper() } factory { AuthenticationInterceptorImpl(get()) } } fun createHttpClientBuilder(addLoggingInterceptor: Boolean = true) = OkHttpClient.Builder() .addInterceptor(createUserAgentInterceptor()) .apply { if (addLoggingInterceptor) { addNetworkInterceptor(createHttpLoggingInterceptor()) } } .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) private fun createHttpClient(context: Context, authenticationInterceptor: AuthenticationInterceptor) = createHttpClientBuilder() .addInterceptor(authenticationInterceptor) .authenticator(authenticationInterceptor) .cache(Cache( directory = File(context.cacheDir, "http_cache"), maxSize = 1024L * 1024L * 5L // 5 MiB )) .build() private fun createHttpLoggingInterceptor() = HttpLoggingInterceptor().apply { level = when (BuildConfig.DEBUG) { true -> HttpLoggingInterceptor.Level.HEADERS false -> HttpLoggingInterceptor.Level.BASIC } redactHeader("Authorization") } fun createUserAgentInterceptor() = UserAgentInterceptor("Kitsune/${BuildConfig.VERSION_NAME}") fun createObjectMapper(): ObjectMapper = jacksonMapperBuilder() .serializationInclusion(JsonInclude.Include.NON_NULL) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, true) .configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false) .addMixIn(Parcelable::class.java, IgnoreParcelablePropertyMixin::class.java) .addModule( SimpleModule().addDeserializer( Filter.Facet.Value::class.java, AlgoliaFacetValueDeserializer() ) ) .addModule( SimpleModule().addDeserializer( Filter.Numeric.Value::class.java, AlgoliaNumericValueDeserializer() ) ) .build() fun createConverterFactory( httpClient: OkHttpClient, objectMapper: ObjectMapper, vararg classes: Class<*> ): JSONAPIConverterFactory { val resourceConverter = ResourceConverter(objectMapper, *classes) resourceConverter.setGlobalResolver { url -> val request = httpClient.newCall(Request.Builder().url(url).build()) request.execute().body?.bytes() } return JSONAPIConverterFactory(resourceConverter) } inline fun createService( httpClient: OkHttpClient, objectMapper: ObjectMapper, vararg classes: Class<*>, baseUrl: String = Kitsu.API_URL ): T { return Retrofit.Builder() .baseUrl(baseUrl) .client(httpClient) .addConverterFactory(createConverterFactory(httpClient, objectMapper, *classes)) .addConverterFactory(JacksonConverterFactory.create(objectMapper)) .build() .create(T::class.java) } inline fun createService( httpClient: OkHttpClient, objectMapper: ObjectMapper, baseUrl: String = Kitsu.API_URL ): T { return Retrofit.Builder() .baseUrl(baseUrl) .client(httpClient) .addConverterFactory(JacksonConverterFactory.create(objectMapper)) .build() .create(T::class.java) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/di/ViewModelModule.kt ================================================ package io.github.drumber.kitsune.di import io.github.drumber.kitsune.ui.authentication.LoginViewModel import io.github.drumber.kitsune.ui.details.DetailsViewModel import io.github.drumber.kitsune.ui.details.characters.CharacterDetailsViewModel import io.github.drumber.kitsune.ui.details.characters.CharactersViewModel import io.github.drumber.kitsune.ui.details.episodes.EpisodesViewModel import io.github.drumber.kitsune.ui.library.LibraryViewModel import io.github.drumber.kitsune.ui.library.editentry.LibraryEditEntryViewModel import io.github.drumber.kitsune.ui.main.MainActivityViewModel import io.github.drumber.kitsune.ui.main.MainFragmentViewModel import io.github.drumber.kitsune.ui.medialist.MediaListViewModel import io.github.drumber.kitsune.ui.onboarding.OnboardingViewModel import io.github.drumber.kitsune.ui.profile.ProfileViewModel import io.github.drumber.kitsune.ui.profile.editprofile.EditProfileViewModel import io.github.drumber.kitsune.ui.search.SearchViewModel import io.github.drumber.kitsune.ui.search.categories.CategoriesViewModel import io.github.drumber.kitsune.ui.settings.AppLogsViewModel import io.github.drumber.kitsune.ui.settings.SettingsViewModel import org.koin.core.module.dsl.viewModel import org.koin.dsl.module val viewModelModule = module { viewModel { OnboardingViewModel(get(), get(), get()) } viewModel { MainActivityViewModel(get(), get()) } viewModel { MainFragmentViewModel(get(), get()) } viewModel { SearchViewModel(get()) } viewModel { MediaListViewModel(get(), get()) } viewModel { CategoriesViewModel(get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { LibraryEditEntryViewModel(get(), get()) } viewModel { LoginViewModel(get()) } viewModel { ProfileViewModel(get(), get()) } viewModel { EditProfileViewModel(get(), get(), get()) } viewModel { DetailsViewModel(get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { EpisodesViewModel(get(), get(), get(), get()) } viewModel { CharactersViewModel(get(), get()) } viewModel { CharacterDetailsViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get()) } viewModel { AppLogsViewModel() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/algolia/FilterCollection.kt ================================================ package io.github.drumber.kitsune.domain.algolia import com.algolia.instantsearch.filter.state.FilterGroupID import com.algolia.instantsearch.filter.state.Filters import com.algolia.search.model.filter.Filter data class FilterCollection( val facetGroups: List> = emptyList(), val tagGroups: List> = emptyList(), val numericGroups: List> = emptyList() ) data class FilterCollectionEntry( val filterGroupID: FilterGroupID, val filters: Set ) fun Filters.toFilterCollection() = FilterCollection( getFacetGroups().toEntryList(), getTagGroups().toEntryList(), getNumericGroups().toEntryList() ) fun FilterCollection.toCombinedMap(): Map> { return facetGroups.toMap() + tagGroups.toMap() + numericGroups.toMap() } private fun Map>.toEntryList(): List> { return this.map { FilterCollectionEntry(it.key, it.value) } } private fun List>.toMap(): Map> { return buildMap { this@toMap.forEach { entry -> put(entry.filterGroupID, entry.filters) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/algolia/SearchProvider.kt ================================================ package io.github.drumber.kitsune.domain.algolia import com.algolia.instantsearch.searcher.hits.HitsSearcher import com.algolia.instantsearch.searcher.hits.SearchForQuery import com.algolia.search.client.ClientSearch import com.algolia.search.logging.LogLevel import com.algolia.search.model.APIKey import com.algolia.search.model.ApplicationID import com.algolia.search.model.IndexName import com.algolia.search.model.search.Query import io.github.drumber.kitsune.BuildConfig import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.common.exception.InvalidDataException import io.github.drumber.kitsune.data.common.exception.SearchProviderUnavailableException import io.github.drumber.kitsune.data.presentation.model.algolia.SearchType import io.github.drumber.kitsune.data.repository.AlgoliaKeyRepository import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.withContext class SearchProvider( private val algoliaKeyRepository: AlgoliaKeyRepository ) { private var clientSearch: ClientSearch? = null private var searcherIndex: HitsSearcher? = null var isInitialized = false private set suspend fun createSearchClient( searchType: SearchType, query: Query, triggerSearchFor: SearchForQuery = SearchForQuery.All, createdListener: suspend (HitsSearcher) -> Unit ) { searcherIndex?.cancel() // cancel any previous created searcher isInitialized = false val algoliaKeys = getAlgoliaKeysAsync().await() ?: throw SearchProviderUnavailableException() val algoliaKey = searchType.getAlgoliaKey(algoliaKeys) val apiKey = algoliaKey?.key ?: throw InvalidDataException("Algolia API Key is null.") val apiIndex = algoliaKey.index ?: throw InvalidDataException("Algolia index is null.") val logLevel = if (BuildConfig.DEBUG) LogLevel.Headers else LogLevel.Info val client = ClientSearch(ApplicationID(Kitsu.ALGOLIA_APP_ID), APIKey(apiKey), logLevel) val searcher = HitsSearcher( client, IndexName(apiIndex), query, triggerSearchFor = triggerSearchFor ) clientSearch = client searcherIndex = searcher isInitialized = true withContext(Dispatchers.Main) { createdListener(searcher) } } private suspend fun getAlgoliaKeysAsync() = withContext(Dispatchers.IO) { async { try { algoliaKeyRepository.getAllAlgoliaKeys() } catch (e: Exception) { logE("Failed to obtain algolia search keys.", e) null } } } fun cancel() { searcherIndex?.cancel() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/auth/IsUserLoggedInUseCase.kt ================================================ package io.github.drumber.kitsune.domain.auth import io.github.drumber.kitsune.data.repository.AccessTokenRepository class IsUserLoggedInUseCase( private val accessTokenRepository: AccessTokenRepository ) { operator fun invoke() = accessTokenRepository.hasAccessToken() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/auth/LogInUserUseCase.kt ================================================ package io.github.drumber.kitsune.domain.auth import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.logI import retrofit2.HttpException class LogInUserUseCase( private val userRepository: UserRepository, private val accessTokenRepository: AccessTokenRepository, private val isUserLoggedIn: IsUserLoggedInUseCase ) { suspend operator fun invoke(username: String, password: String): LoginResult { if (isUserLoggedIn()) { logI("Login: Did not log in because the user is already logged in.") return LoginResult.AlreadyLoggedIn } logI("Login: Obtaining access token.") val accessToken = try { accessTokenRepository.obtainAccessToken(username, password) } catch (e: HttpException) { logE("Login: Failed to obtain access token.", e) return when (e.code()) { 400 -> LoginResult.Failure else -> LoginResult.Error(e) } } catch (e: Exception) { logE("Login: Failed to obtain access token.", e) return LoginResult.Error(e) } val localUser = try { userRepository.fetchAndStoreLocalUserFromNetwork() userRepository.localUser.value } catch (e: Exception) { logE("Login: Failed to update local user from network.", e) null } logI("Login: Successfully logged in.") return LoginResult.Success(accessToken, localUser) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/auth/LogOutUserUseCase.kt ================================================ package io.github.drumber.kitsune.domain.auth import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.util.logI class LogOutUserUseCase( private val userRepository: UserRepository, private val accessTokenRepository: AccessTokenRepository ) { suspend operator fun invoke() { logI("Logout: Clearing access token and local user.") accessTokenRepository.clearAccessToken() userRepository.clearLocalUser() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/auth/LoginResult.kt ================================================ package io.github.drumber.kitsune.domain.auth import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken import io.github.drumber.kitsune.data.source.local.user.model.LocalUser sealed interface LoginResult { /** Successfully logged in */ data class Success(val accessToken: LocalAccessToken, val localUser: LocalUser?) : LoginResult /** Login failed, e.g. wrong credentials. */ data object Failure : LoginResult /** User is already logged in */ data object AlreadyLoggedIn : LoginResult /** Login failed due to an error. */ data class Error(val exception: Exception) : LoginResult } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/auth/RefreshAccessTokenIfExpiredUseCase.kt ================================================ package io.github.drumber.kitsune.domain.auth import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken import io.github.drumber.kitsune.util.logI import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds class RefreshAccessTokenIfExpiredUseCase( private val accessTokenRepository: AccessTokenRepository, private val refreshAccessToken: RefreshAccessTokenUseCase ) { companion object { val REFRESH_PRIOR_TIME = 1.days // refresh access token one day before it expires } suspend operator fun invoke(): RefreshResult? { val accessToken = accessTokenRepository.getAccessToken() ?: return null if (accessToken.isAccessTokenConsideredExpired()) { logI("Refresh: Access token is considered expired.") return refreshAccessToken() } return null } /** * Check if the access token is considered as expired. * * The access token is considered expired if the time specified with [REFRESH_PRIOR_TIME] has elapsed before the expiry date. */ private fun LocalAccessToken.isAccessTokenConsideredExpired(): Boolean { val expirationTime = getExpirationTimeInSeconds().seconds return System.currentTimeMillis() >= (expirationTime - REFRESH_PRIOR_TIME).inWholeMilliseconds } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/auth/RefreshAccessTokenUseCase.kt ================================================ package io.github.drumber.kitsune.domain.auth import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.logI import retrofit2.HttpException class RefreshAccessTokenUseCase( private val accessTokenRepository: AccessTokenRepository, private val userRepository: UserRepository, private val logOutUser: LogOutUserUseCase ) { suspend operator fun invoke(): RefreshResult { logI("Refresh: Refreshing access token.") val accessToken = try { accessTokenRepository.refreshAccessToken() } catch (e: HttpException) { // trigger logout if the refresh token is invalid if (e.code() in 400..499) { logE("Refresh: Failed with status code ${e.code()}. Triggering logout...", e) triggerLogOutWithLoginPrompt() return RefreshResult.Failure } logE("Refresh: Failed to refresh access token.", e) return RefreshResult.Error(e) } catch (e: Exception) { logE("Refresh: Failed to refresh access token.", e) return RefreshResult.Error(e) } logI("Refresh: Successfully refreshed access token.") return RefreshResult.Success(accessToken) } /** * Log out the current user and prompt for re-login. */ private suspend fun triggerLogOutWithLoginPrompt() { logOutUser() userRepository.promptUserReLogIn() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/auth/RefreshResult.kt ================================================ package io.github.drumber.kitsune.domain.auth import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken sealed interface RefreshResult { /** The access token was successfully refreshed. */ data class Success(val accessToken: LocalAccessToken) : RefreshResult /** The access token could not be refreshed, e.g. invalid or expired refresh token. */ data object Failure : RefreshResult /** Refresh failed due to an error. */ data class Error(val exception: Exception) : RefreshResult } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/FetchLibraryEntriesForWidgetUseCase.kt ================================================ package io.github.drumber.kitsune.domain.library import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.library.LibraryEntryKind import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryFilter import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.domain.user.GetLocalUserIdUseCase import io.github.drumber.kitsune.util.logE class FetchLibraryEntriesForWidgetUseCase( private val libraryRepository: LibraryRepository, private val getLocalUserId: GetLocalUserIdUseCase ) { suspend operator fun invoke(count: Int) { val userId = getLocalUserId() ?: return val requestFilter = Filter() .filter("user_id", userId) .sort("status", "-progressed_at") .include("anime", "manga") val filter = LibraryEntryFilter( kind = LibraryEntryKind.All, libraryStatus = listOf(LibraryStatus.Current), initialFilter = requestFilter ).pageSize(count) try { libraryRepository.fetchAndStoreLibraryEntriesForFilter(filter) } catch (e: Exception) { logE("Failed to fetch library entries for widget", e) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/GetLibraryEntriesWithModificationsPagerUseCase.kt ================================================ package io.github.drumber.kitsune.domain.library import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryFilter import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.repository.LibraryRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine class GetLibraryEntriesWithModificationsPagerUseCase( private val libraryRepository: LibraryRepository ) { operator fun invoke( pageSize: Int, filter: LibraryEntryFilter, cacheScope: CoroutineScope ): Flow> { return libraryRepository.libraryEntriesPager(pageSize, filter) .cachedIn(cacheScope) .combine(libraryRepository.getLibraryEntryModificationsAsFlow()) { pagingData, modifications -> pagingData.map { entry -> LibraryEntryWithModification( entry, modifications.find { it.id == entry.id } ) } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/LibraryEntryUpdateResult.kt ================================================ package io.github.drumber.kitsune.domain.library import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry sealed class LibraryEntryUpdateResult { data class Success(val updatedLibraryEntry: LibraryEntry) : LibraryEntryUpdateResult() data class Failure(val reason: LibraryEntryUpdateFailureReason) : LibraryEntryUpdateResult() } sealed class LibraryEntryUpdateFailureReason { data object NotFound : LibraryEntryUpdateFailureReason() data class NetworkError(val exception: Exception) : LibraryEntryUpdateFailureReason() data class UnknownException(val exception: Exception) : LibraryEntryUpdateFailureReason() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/SearchLibraryEntriesWithLocalModificationsPagerUseCase.kt ================================================ package io.github.drumber.kitsune.domain.library import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.map import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.repository.LibraryRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine class SearchLibraryEntriesWithLocalModificationsPagerUseCase( private val libraryRepository: LibraryRepository ) { operator fun invoke( pageSize: Int, filter: Filter, cacheScope: CoroutineScope ): Flow> { return libraryRepository.searchLibraryEntriesPager(pageSize, filter) .cachedIn(cacheScope) .combine(libraryRepository.getLibraryEntryModificationsAsFlow()) { pagingData, modifications -> pagingData.map { entry -> LibraryEntryWithModification( entry, modifications.find { it.id == entry.id } ) } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/SynchronizeLocalLibraryModificationsUseCase.kt ================================================ package io.github.drumber.kitsune.domain.library import io.github.drumber.kitsune.data.repository.LibraryRepository class SynchronizeLocalLibraryModificationsUseCase( private val libraryRepository: LibraryRepository, private val updateLibraryEntry: UpdateLibraryEntryUseCase ) { suspend operator fun invoke(): Map { return libraryRepository.getAllLibraryEntryModifications() .associate { libraryEntryModification -> libraryEntryModification.id to updateLibraryEntry( libraryEntryModification ) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/UpdateLibraryEntryProgressUseCase.kt ================================================ package io.github.drumber.kitsune.domain.library import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification import io.github.drumber.kitsune.util.formatUtcDate import io.github.drumber.kitsune.util.getLocalCalendar class UpdateLibraryEntryProgressUseCase( private val updateLibraryEntry: UpdateLibraryEntryUseCase ) { suspend operator fun invoke( libraryEntry: LibraryEntry, newProgress: Int ): LibraryEntryUpdateResult { var modification = LibraryEntryModification.withIdAndNulls(libraryEntry.id) .copy(progress = newProgress) // set startedAt date when starting consuming library entry if ( libraryEntry.startedAt.isNullOrBlank() && newProgress == 1 && (libraryEntry.progress ?: 0) == 0 ) { modification = modification.copy( startedAt = getLocalCalendar().formatUtcDate() ) } return updateLibraryEntry(modification) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/UpdateLibraryEntryRatingUseCase.kt ================================================ package io.github.drumber.kitsune.domain.library import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification class UpdateLibraryEntryRatingUseCase( private val updateLibraryEntry: UpdateLibraryEntryUseCase ) { suspend operator fun invoke( libraryEntry: LibraryEntry, rating: Int? ): LibraryEntryUpdateResult { val updatedRating = rating ?: -1 // '-1' will be mapped to 'null' by the json serializer val modification = LibraryEntryModification.withIdAndNulls(libraryEntry.id) .copy(ratingTwenty = updatedRating) return updateLibraryEntry(modification) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/library/UpdateLibraryEntryUseCase.kt ================================================ package io.github.drumber.kitsune.domain.library import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateFailureReason.NetworkError import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateFailureReason.NotFound import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateFailureReason.UnknownException import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult.Failure import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult.Success import io.github.drumber.kitsune.data.common.exception.NotFoundException import java.io.IOException import kotlin.coroutines.cancellation.CancellationException class UpdateLibraryEntryUseCase( private val libraryRepository: LibraryRepository ) { suspend operator fun invoke(modification: LibraryEntryModification): LibraryEntryUpdateResult { return try { val updatedLibraryEntry = libraryRepository.updateLibraryEntry(modification) Success(updatedLibraryEntry) } catch (e: CancellationException) { throw e } catch (e: NotFoundException) { Failure(NotFound) } catch (e: IOException) { Failure(NetworkError(e)) } catch (e: Exception) { Failure(UnknownException(e)) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/user/GetLocalUserIdUseCase.kt ================================================ package io.github.drumber.kitsune.domain.user import io.github.drumber.kitsune.data.repository.UserRepository class GetLocalUserIdUseCase( private val userRepository: UserRepository ) { operator fun invoke(): String? { return userRepository.localUser.value?.id } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/user/UpdateLocalUserUseCase.kt ================================================ package io.github.drumber.kitsune.domain.user import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.domain.auth.IsUserLoggedInUseCase import io.github.drumber.kitsune.domain.auth.RefreshAccessTokenIfExpiredUseCase import io.github.drumber.kitsune.domain.auth.RefreshResult import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.logI class UpdateLocalUserUseCase( private val userRepository: UserRepository, private val isUserLoggedIn: IsUserLoggedInUseCase, private val refreshAccessTokenIfExpired: RefreshAccessTokenIfExpiredUseCase ) { suspend operator fun invoke() { if (!isUserLoggedIn()) { logI("Cannot update local user: User is not logged in.") return } val refreshResult = refreshAccessTokenIfExpired() if (refreshResult != null && refreshResult !is RefreshResult.Success) { logI("Cannot update local user: Access token refresh failed.") return } try { userRepository.fetchAndStoreLocalUserFromNetwork() } catch (e: Exception) { logE("Failed to update local user model from network.", e) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/domain/work/UpdateLibraryWidgetUseCase.kt ================================================ package io.github.drumber.kitsune.domain.work import android.content.Context import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import io.github.drumber.kitsune.work.UpdateLibraryWidgetWorker class UpdateLibraryWidgetUseCase { operator fun invoke(context: Context) { val updateWidgetWork = OneTimeWorkRequestBuilder() .build() WorkManager.getInstance(context).enqueueUniqueWork( UpdateLibraryWidgetWorker.TAG, ExistingWorkPolicy.REPLACE, updateWidgetWork ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/notification/NotificationChannels.kt ================================================ package io.github.drumber.kitsune.notification import android.content.Context import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat.IMPORTANCE_HIGH import io.github.drumber.kitsune.R object NotificationChannels { const val CHANNEL_UPDATE_CHECKER = "update_checker" const val ID_NEW_VERSION = 10 fun registerNotificationChannels(context: Context) { val notificationManager = NotificationManagerCompat.from(context) notificationManager.createNotificationChannelsCompat( listOf( notificationChannel(CHANNEL_UPDATE_CHECKER, IMPORTANCE_HIGH) { setName(context.getString(R.string.notification_updates)) setShowBadge(false) } ) ) } private fun notificationChannel( id: String, importance: Int, block: NotificationChannelCompat.Builder.() -> Unit ): NotificationChannelCompat { return NotificationChannelCompat.Builder(id, importance).apply(block).build() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/notification/Notifications.kt ================================================ package io.github.drumber.kitsune.notification import android.Manifest import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.appupdate.AppRelease import io.github.drumber.kitsune.notification.NotificationChannels.CHANNEL_UPDATE_CHECKER object Notifications { fun showNewVersion(context: Context, release: AppRelease) { val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(release.url)) val releaseIntent = PendingIntent.getActivity( context, 0, browserIntent, PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE ) val notification = NotificationCompat.Builder(context, CHANNEL_UPDATE_CHECKER) .setSmallIcon(R.drawable.ic_notification_icon) .setContentTitle(context.getString(R.string.info_update_new_version_available)) .setContentText(context.getString(R.string.info_update_new_version_available_text, release.version)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setContentIntent(releaseIntent) .setAutoCancel(true) .build() if (ActivityCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { return } NotificationManagerCompat.from(context).notify(NotificationChannels.ID_NEW_VERSION, notification) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/preference/CategoryPrefWrapper.kt ================================================ package io.github.drumber.kitsune.preference data class CategoryPrefWrapper( val categoryId: String? = null, val categoryName: String? = null, val categorySlug: String? = null, val parentIds: List? = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/preference/KitsunePref.kt ================================================ package io.github.drumber.kitsune.preference import androidx.appcompat.app.AppCompatDelegate import androidx.lifecycle.asFlow import com.chibatching.kotpref.KotprefModel import com.chibatching.kotpref.enumpref.enumOrdinalPref import com.chibatching.kotpref.enumpref.enumValuePref import com.chibatching.kotpref.livedata.asLiveData import com.fasterxml.jackson.core.JsonProcessingException import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.AppTheme import io.github.drumber.kitsune.constants.MediaItemSize import io.github.drumber.kitsune.data.common.library.LibraryEntryKind import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.data.source.local.user.model.LocalRatingSystemPreference import io.github.drumber.kitsune.data.source.local.user.model.LocalTitleLanguagePreference import io.github.drumber.kitsune.domain.algolia.FilterCollection import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.flow.combine import org.koin.core.component.KoinComponent import org.koin.core.component.get import org.koin.core.component.inject import kotlin.reflect.KProperty object KitsunePref : KotprefModel(), KoinComponent { override val commitAllPropertiesByDefault = true override val kotprefName = context.getString(R.string.preference_file_key) private val userRepository: UserRepository by inject() private var titlesIntern by enumValuePref( LocalTitleLanguagePreference.Canonical, key = R.string.preference_key_titles ) var titles: LocalTitleLanguagePreference set(value) { titlesIntern = value } get() { return userRepository.localUser.value?.titleLanguagePreference ?: titlesIntern } var appTheme by enumValuePref(AppTheme.DEFAULT) var useDynamicColorTheme by booleanPref( false, key = R.string.preference_key_dynamic_color_theme ) var darkMode by stringPref( AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM.toString(), key = R.string.preference_key_dark_mode ) var oledBlackMode by booleanPref( false, key = R.string.preference_key_oled_black_mode ) var mediaItemSize by enumOrdinalPref(MediaItemSize.MEDIUM) var startFragment by enumValuePref(StartPagePref.Home) var rememberSearchFilters by booleanPref( true, key = R.string.preference_key_remember_search_filters ) var forceLegacyImagePicker by booleanPref( false, key = R.string.preference_key_force_legacy_image_picker ) var checkForUpdatesOnStart by booleanPref( false, key = R.string.preference_key_check_for_updates_on_start ) private var searchFiltersJson by stringPref("{}") var searchFilters: FilterCollection set(value) { searchFiltersJson = value.toJsonString() } get() = ::searchFiltersJson.fromJsonString(FilterCollection()) private var searchCategoriesJson by stringPref("[]") var searchCategories: List set(value) { searchCategoriesJson = value.toJsonString() } get() = ::searchCategoriesJson.fromJsonString(emptyList()) var libraryEntryKind by enumValuePref(LibraryEntryKind.All) private var libraryEntryStatusJson by stringPref("[]") var libraryEntryStatus: List set(value) { libraryEntryStatusJson = value.toJsonString() } get() = ::libraryEntryStatusJson.fromJsonString(emptyList()) var flagUserDeniedNotificationPermission by booleanPref(false) var ratingChartRatingSystem by enumValuePref(LocalRatingSystemPreference.Regular) var lastLibraryFetchForWidget by longPref(default = -1L) var lastUpdateCheck by longPref(default = -1L) var onboardingFinishedVersionCode by intPref(default = -1) private fun Any.toJsonString(): String { val objectMapper: ObjectMapper = get() return objectMapper.writeValueAsString(this) } private inline fun KProperty<*>.fromJsonString(defaultValue: T): T { val value = preferences.getString(getPrefKey(this), null) ?: return defaultValue val objectMapper: ObjectMapper = get() return try { objectMapper.readValue(value) } catch (e: JsonProcessingException) { logE("Failed to parse object from JSON. Returning default value.", e) remove(this) // reset preference defaultValue } } fun getTitleLanguageAsFlow() = KitsunePref.asLiveData(KitsunePref::titlesIntern) .asFlow() .combine(userRepository.localUser) { preference, localUser -> localUser?.titleLanguagePreference ?: preference } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/preference/StartPagePref.kt ================================================ package io.github.drumber.kitsune.preference import io.github.drumber.kitsune.R enum class StartPagePref { Home, Search, Library, Profile } fun StartPagePref.getDestinationId() = when (this) { StartPagePref.Home -> R.id.main_fragment StartPagePref.Search -> R.id.search_fragment StartPagePref.Library -> R.id.library_fragment StartPagePref.Profile -> R.id.profile_fragment } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/AbstractMediaRecyclerViewAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.core.view.ViewCompat import androidx.databinding.OnRebindCallback import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.databinding.ItemMediaBinding import io.github.drumber.kitsune.ui.adapter.AbstractMediaRecyclerViewAdapter.AbstractMediaViewHolder import java.util.concurrent.CopyOnWriteArrayList abstract class AbstractMediaRecyclerViewAdapter, Item>( val dataSet: CopyOnWriteArrayList, private val glide: RequestManager, private val transitionNameSuffix: String?, private val listener: OnItemClickListener? ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val binding = ItemMediaBinding.inflate( LayoutInflater.from(parent.context), parent, false ) binding.apply { contentWrapper.layoutParams.width = FrameLayout.LayoutParams.WRAP_CONTENT cardMedia.isInGridLayout = false } binding.addOnRebindCallback(object : OnRebindCallback() { override fun onBound(binding: ItemMediaBinding) { // If the same media (with the same ID) is shown in more than one recyclerview, // the shared-element-transition won't work. To make the transition name unique again, // we may add a suffix (e.g. the section title on the home page). transitionNameSuffix?.let { binding.cardMedia.fixUniqueTransitionName(it) } } }) return onCreateViewHolder(binding, glide) { view, position -> if (position < dataSet.size) { listener?.onItemClick(view, dataSet[position]) } } } abstract fun onCreateViewHolder( binding: ItemMediaBinding, glide: RequestManager, listener: (View, Int) -> Unit ): ViewHolder /** Add a suffix to the transitionName if not already present. */ private fun View.fixUniqueTransitionName(suffix: String) { ViewCompat.getTransitionName(this)?.let { transitionName -> if (!transitionName.endsWith(suffix)) ViewCompat.setTransitionName(this, transitionName + suffix) } } override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.bind(dataSet[position]) } override fun getItemCount() = dataSet.size abstract class AbstractMediaViewHolder( binding: ItemMediaBinding, onClick: (View, Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { init { binding.cardMedia.setOnClickListener { val position = bindingAdapterPosition if (position != RecyclerView.NO_POSITION) { onClick(it, position) } } } abstract fun bind(data: T) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/CharacterAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.databinding.ItemSingleCharacterBinding import java.util.concurrent.CopyOnWriteArrayList class CharacterAdapter( val dataSet: CopyOnWriteArrayList, private val glide: RequestManager, private val listener: OnItemClickListener? = null ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SingleCharacterViewHolder { return SingleCharacterViewHolder( ItemSingleCharacterBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindViewHolder(holder: SingleCharacterViewHolder, position: Int) { holder.bind(dataSet[position]) } override fun getItemCount() = dataSet.size inner class SingleCharacterViewHolder(private val binding: ItemSingleCharacterBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(character: Character) { binding.cardCharacter.setOnClickListener { listener?.onItemClick(binding.cardCharacter, character) } glide.load(character.image?.originalOrDown()) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivCharacter) binding.tvName.text = character.name } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/MediaCharacterAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.LayoutInflater import android.view.ViewGroup import android.widget.FrameLayout import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.MediaItemSize import io.github.drumber.kitsune.data.presentation.model.character.MediaCharacter import io.github.drumber.kitsune.data.presentation.model.character.getStringRes import io.github.drumber.kitsune.databinding.ItemMediaBinding import java.util.concurrent.CopyOnWriteArrayList class MediaCharacterAdapter( val dataSet: CopyOnWriteArrayList, private val glide: RequestManager, private val listener: OnItemClickListener? = null ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaCharacterViewHolder { return MediaCharacterViewHolder( ItemMediaBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindViewHolder(holder: MediaCharacterViewHolder, position: Int) { holder.bind(dataSet[position]) } override fun getItemCount() = dataSet.size inner class MediaCharacterViewHolder(private val binding: ItemMediaBinding) : RecyclerView.ViewHolder(binding.root) { init { binding.apply { contentWrapper.layoutParams.width = FrameLayout.LayoutParams.WRAP_CONTENT cardMedia.setCustomItemSize(MediaItemSize.SMALL) cardMedia.isInGridLayout = false } } fun bind(data: MediaCharacter) { val media = data.media binding.data = media binding.overlayTagText = data.role?.getStringRes()?.let { binding.root.context.getString(it) } glide.load(media?.posterImageUrl) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) binding.cardMedia.setOnClickListener { listener?.onItemClick(binding.cardMedia, data) } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/MediaMappingsAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.app.SearchManager import android.content.Context import android.content.Intent import android.net.Uri import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter import com.bumptech.glide.Glide import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.mapping.Mapping import io.github.drumber.kitsune.data.presentation.model.mapping.getExternalUrl import io.github.drumber.kitsune.data.presentation.model.mapping.getSiteName import io.github.drumber.kitsune.databinding.ItemMediaMappingBinding import io.github.drumber.kitsune.util.extensions.copyToClipboard import io.github.drumber.kitsune.util.extensions.showSomethingWrongToast import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.logE class MediaMappingsAdapter( private val context: Context, val dataSource: MutableList ) : BaseAdapter() { override fun getCount(): Int = dataSource.size override fun getItem(position: Int) = dataSource[position] override fun getItemId(position: Int): Long = position.toLong() override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { val binding = if (convertView == null) { ItemMediaMappingBinding.inflate(LayoutInflater.from(context), parent, false) } else { ItemMediaMappingBinding.bind(convertView) } val mapping = getItem(position) binding.tvSiteName.text = mapping.getSiteName() ?: mapping.externalSite ?: "?" binding.tvSiteUrl.text = mapping.getExternalUrl() ?: mapping.externalId ?: "-" mapping.getExternalUrl()?.let { url -> try { val domain = Uri.parse(url).host Glide.with(context) .load("https://icons.duckduckgo.com/ip3/$domain.ico") .placeholder(R.drawable.ic_website) .into(binding.ivSiteIcon) } catch (e: Exception) { logD("Failed to load favicon for url: $url", e) } } binding.root.setOnClickListener { val url = mapping.getExternalUrl() if (url != null) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) try { context.startActivity(intent) } catch (e: Exception) { logE("Failed to open URL: $url", e) context.showSomethingWrongToast() } } else { // do a web search val query = "${mapping.externalSite} ${mapping.externalId}" val intent = Intent(Intent.ACTION_WEB_SEARCH) intent.putExtra(SearchManager.QUERY, query) try { context.startActivity(intent) } catch (e: Exception) { logE("Failed to do a web search for: $query", e) context.showSomethingWrongToast() } } } binding.root.setOnLongClickListener { val url = mapping.getExternalUrl() ?: mapping.externalId ?: return@setOnLongClickListener false context.copyToClipboard(mapping.getSiteName() ?: "URL", url) return@setOnLongClickListener true } return binding.root } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/MediaRecyclerViewAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.View import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.constants.MediaItemSize import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.ItemMediaBinding import java.util.concurrent.CopyOnWriteArrayList class MediaRecyclerViewAdapter( dataSet: CopyOnWriteArrayList, glide: RequestManager, private val showSubtype: Boolean = false, private val itemSize: MediaItemSize? = null, transitionNameSuffix: String? = null, listener: OnItemClickListener? = null ) : AbstractMediaRecyclerViewAdapter( dataSet, glide, transitionNameSuffix, listener ) { var overrideItemSize: MediaItemSize? = null override fun onCreateViewHolder( binding: ItemMediaBinding, glide: RequestManager, listener: (View, Int) -> Unit ): MediaViewHolder { itemSize?.let { binding.cardMedia.setCustomItemSize(it) } return MediaViewHolder( binding, glide, showSubtype, listener ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/MediaRelationshipRecyclerViewAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.View import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.constants.MediaItemSize import io.github.drumber.kitsune.data.presentation.model.media.relationship.MediaRelationship import io.github.drumber.kitsune.databinding.ItemMediaBinding import java.util.concurrent.CopyOnWriteArrayList class MediaRelationshipRecyclerViewAdapter( dataSet: CopyOnWriteArrayList, glide: RequestManager, transitionNameSuffix: String? = null, listener: OnItemClickListener? = null ) : AbstractMediaRecyclerViewAdapter( dataSet, glide, transitionNameSuffix, listener ) { override fun onCreateViewHolder( binding: ItemMediaBinding, glide: RequestManager, listener: (View, Int) -> Unit ): MediaRelationshipViewHolder { binding.cardMedia.setCustomItemSize(MediaItemSize.SMALL) return MediaRelationshipViewHolder( binding, glide, listener ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/MediaRelationshipViewHolder.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.View import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.media.relationship.MediaRelationship import io.github.drumber.kitsune.data.presentation.model.media.relationship.getStringRes import io.github.drumber.kitsune.databinding.ItemMediaBinding import io.github.drumber.kitsune.ui.adapter.AbstractMediaRecyclerViewAdapter.AbstractMediaViewHolder class MediaRelationshipViewHolder( private val binding: ItemMediaBinding, private val glide: RequestManager, onClick: (View, Int) -> Unit ) : AbstractMediaViewHolder(binding, onClick) { override fun bind(data: MediaRelationship) { binding.data = data.media binding.overlayTagText = data.role?.getStringRes() ?.let { binding.root.context.getString(it) } data.media?.posterImageUrl?.let { glide.load(it) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/MediaViewHolder.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.View import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.ItemMediaBinding import io.github.drumber.kitsune.util.fixImageUrl class MediaViewHolder( private val binding: ItemMediaBinding, private val glide: RequestManager, private val showSubtype: Boolean = false, listener: (View, Int) -> Unit ) : AbstractMediaRecyclerViewAdapter.AbstractMediaViewHolder(binding, listener) { override fun bind(data: Media) { binding.data = data binding.overlayTagText = when (showSubtype) { false -> null true -> data.subtypeFormatted } glide.load(data.posterImageUrl?.fixImageUrl()) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/OnItemClickListener.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.View fun interface OnItemClickListener { fun onItemClick(view: View, item: T) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/StreamingLinkAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter import android.view.LayoutInflater import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.StreamingLogo import io.github.drumber.kitsune.data.presentation.model.media.streamer.StreamingLink import io.github.drumber.kitsune.databinding.ItemStreamerBinding import java.util.concurrent.CopyOnWriteArrayList class StreamingLinkAdapter( val dataSet: CopyOnWriteArrayList = CopyOnWriteArrayList(), private val glide: RequestManager, private val listener: OnItemClickListener? = null ) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StreamingLinkViewHolder { val binding = ItemStreamerBinding.inflate(LayoutInflater.from(parent.context), parent, false) return StreamingLinkViewHolder(binding) { position -> if (position < dataSet.size) { listener?.onItemClick(binding.root, dataSet[position]) } } } override fun onBindViewHolder(holder: StreamingLinkViewHolder, position: Int) { holder.bind(dataSet[position]) } override fun getItemCount() = dataSet.size inner class StreamingLinkViewHolder( private val binding: ItemStreamerBinding, private val listener: (Int) -> Unit ) : RecyclerView.ViewHolder(binding.root) { init { binding.root.setOnClickListener { val position = bindingAdapterPosition if(position != RecyclerView.NO_POSITION) { listener(position) } } } fun bind(streamingLink: StreamingLink) { val logo = streamingLink.streamer?.siteName?.let { siteName -> StreamingLogo.entries.find { it.name.equals(siteName, true) }?.drawable } glide.load(logo) .centerInside() .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivLogo) TooltipCompat.setTooltipText(binding.root, streamingLink.streamer?.siteName) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/AnimeAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.ui.adapter.OnItemClickListener class AnimeAdapter(glide: RequestManager, listener: OnItemClickListener? = null) : MediaPagingAdapter(AnimeComparator, glide, listener) { object AnimeComparator: DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Anime, newItem: Anime) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Anime, newItem: Anime) = oldItem == newItem } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/CharacterPagingAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.presentation.model.media.production.Casting import io.github.drumber.kitsune.databinding.ItemCharacterBinding import io.github.drumber.kitsune.ui.adapter.OnItemClickListener class CharacterPagingAdapter( private val glide: RequestManager, private val characterClickListener: OnItemClickListener? = null ) : PagingDataAdapter(CharacterComparator) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterViewHolder { return CharacterViewHolder( ItemCharacterBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(holder: CharacterViewHolder, position: Int) { if (position >= itemCount) return getItem(position)?.let { holder.bind(it) } } inner class CharacterViewHolder(private val binding: ItemCharacterBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(casting: Casting) { val imgCharacter = casting.character?.image?.original val imgActor = casting.person?.image?.original glide.load(imgCharacter) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivCharacter) glide.load(imgActor) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivActor) binding.apply { tvCharacterName.text = casting.character?.name tvActorName.text = casting.person?.name ivCharacter.isVisible = imgCharacter != null || !tvCharacterName.text.isNullOrBlank() ivActor.isVisible = imgActor != null || !tvActorName.text.isNullOrBlank() ivCharacter.setOnClickListener { casting.character?.let { character -> characterClickListener?.onItemClick(ivCharacter, character) } } } } } object CharacterComparator : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Casting, newItem: Casting) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Casting, newItem: Casting) = oldItem == newItem } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/LibraryEntriesAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryUiModel import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryUiModel.EntryModel import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryUiModel.StatusSeparatorModel import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.presentation.model.library.getStringResId import io.github.drumber.kitsune.databinding.ItemLibraryEntryBinding import io.github.drumber.kitsune.databinding.ItemLibraryStatusSeparatorBinding class LibraryEntriesAdapter( private val glide: RequestManager, private val listener: LibraryEntryActionListener? = null ) : PagingDataAdapter(LibraryEntryUiModelComparator) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = when (viewType) { R.layout.item_library_entry -> LibraryEntryViewHolder( ItemLibraryEntryBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) else -> StatusSeparatorViewHolder( ItemLibraryStatusSeparatorBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun getItemViewType(position: Int): Int { if (position >= itemCount) return 0 return when (peek(position)) { is EntryModel -> R.layout.item_library_entry is StatusSeparatorModel -> R.layout.item_library_status_separator else -> 0 } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { if (position >= itemCount) return val item = getItem(position) ?: return when (holder) { is LibraryEntryViewHolder -> holder.bind((item as EntryModel).entry) is StatusSeparatorViewHolder -> holder.bind(item as StatusSeparatorModel) } } inner class LibraryEntryViewHolder( private val binding: ItemLibraryEntryBinding ) : RecyclerView.ViewHolder(binding.root) { init { binding.apply { cardView.setOnClickListener { if (bindingAdapterPosition != RecyclerView.NO_POSITION) { getItem(bindingAdapterPosition)?.let { listener?.onItemClicked(cardView, (it as EntryModel).entry) } } } cardView.setOnLongClickListener { if (bindingAdapterPosition != RecyclerView.NO_POSITION) { getItem(bindingAdapterPosition)?.let { listener?.onItemLongClicked((it as EntryModel).entry) } return@setOnLongClickListener true } return@setOnLongClickListener false } btnWatchedAdd.setOnClickListener { if (bindingAdapterPosition != RecyclerView.NO_POSITION) { getItem(bindingAdapterPosition)?.let { listener?.onEpisodeWatchedClicked((it as EntryModel).entry) } } } btnWatchedRemoved.setOnClickListener { if (bindingAdapterPosition != RecyclerView.NO_POSITION) { getItem(bindingAdapterPosition)?.let { listener?.onEpisodeUnwatchedClicked((it as EntryModel).entry) } } } btnRating.setOnClickListener { if (bindingAdapterPosition != RecyclerView.NO_POSITION) { getItem(bindingAdapterPosition)?.let { listener?.onRatingClicked((it as EntryModel).entry) } } } } } fun bind(libraryEntry: LibraryEntryWithModification) { binding.entry = libraryEntry glide.load(libraryEntry.media?.posterImageUrl) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) binding.tvNotSynced.isVisible = libraryEntry.isNotSynced binding.tvTitle.maxLines = if (libraryEntry.isNotSynced) 2 else 3 } } inner class StatusSeparatorViewHolder( private val binding: ItemLibraryStatusSeparatorBinding ) : RecyclerView.ViewHolder(binding.root) { fun bind(statusSeparator: StatusSeparatorModel) { binding.tvTitle.setText(statusSeparator.status.getStringResId(!statusSeparator.isMangaSelected)) } } object LibraryEntryUiModelComparator : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: LibraryEntryUiModel, newItem: LibraryEntryUiModel ): Boolean { val isSameLibraryEntry = oldItem is EntryModel && newItem is EntryModel && oldItem.entry.id == newItem.entry.id val isSameSeparator = oldItem is StatusSeparatorModel && newItem is StatusSeparatorModel && oldItem.status == newItem.status return isSameLibraryEntry || isSameSeparator } override fun areContentsTheSame( oldItem: LibraryEntryUiModel, newItem: LibraryEntryUiModel ) = oldItem == newItem } interface LibraryEntryActionListener { fun onItemClicked(view: View, item: LibraryEntryWithModification) fun onItemLongClicked(item: LibraryEntryWithModification) fun onEpisodeWatchedClicked(item: LibraryEntryWithModification) fun onEpisodeUnwatchedClicked(item: LibraryEntryWithModification) fun onRatingClicked(item: LibraryEntryWithModification) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/MangaAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.ui.adapter.OnItemClickListener class MangaAdapter(glide: RequestManager, listener: OnItemClickListener? = null) : MediaPagingAdapter(MangaComparator, glide, listener) { object MangaComparator: DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Manga, newItem: Manga) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Manga, newItem: Manga) = oldItem == newItem } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/MediaPagingAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.ItemMediaBinding import io.github.drumber.kitsune.ui.adapter.MediaViewHolder import io.github.drumber.kitsune.ui.adapter.OnItemClickListener sealed class MediaPagingAdapter( diffCallback: DiffUtil.ItemCallback, private val glide: RequestManager, private val listener: OnItemClickListener? = null ) : PagingDataAdapter(diffCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { val binding = ItemMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.cardMedia.isInGridLayout = true return MediaViewHolder( binding, glide ) { _, position -> getItem(position)?.let { item -> listener?.onItemClick(binding.cardMedia, item) } } } override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { if (position >= itemCount) return getItem(position)?.let { holder.bind(it) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/MediaSearchPagingAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import android.view.LayoutInflater import android.view.ViewGroup import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.ItemMediaBinding import io.github.drumber.kitsune.ui.adapter.MediaViewHolder import io.github.drumber.kitsune.ui.adapter.OnItemClickListener class MediaSearchPagingAdapter( private val glide: RequestManager, private val listener: OnItemClickListener? = null ) : PagingDataAdapter(MediaSearchComparator) { override fun onBindViewHolder(holder: MediaViewHolder, position: Int) { if (position >= itemCount) return getItem(position)?.let { holder.bind(it) } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaViewHolder { val binding = ItemMediaBinding.inflate(LayoutInflater.from(parent.context), parent, false) binding.cardMedia.isInGridLayout = true return MediaViewHolder( binding, glide, showSubtype = true ) { _, position -> getItem(position)?.let { item -> listener?.onItemClick(binding.cardMedia, item) } } } object MediaSearchComparator : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Media, newItem: Media) = oldItem.mediaType == newItem.mediaType && oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Media, newItem: Media) = oldItem == newItem } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/MediaUnitPagingAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.media.unit.MediaUnit import io.github.drumber.kitsune.databinding.ItemEpisodeBinding import kotlin.math.max class MediaUnitPagingAdapter( private val glide: RequestManager, private val posterUrl: String?, private var enableWatchedCheckbox: Boolean, private val listener: MediaUnitActionListener ) : PagingDataAdapter(MediaUnitComparator) { private var numberWatched = 0 fun updateLibraryWatchCount(numberWatched: Int) { val oldValue = this.numberWatched this.numberWatched = numberWatched notifyItemRangeChanged(0, max(oldValue, numberWatched)) } fun setIsWatchedCheckboxEnabled(isEnabled: Boolean) { if (isEnabled == enableWatchedCheckbox) return enableWatchedCheckbox = isEnabled notifyItemRangeChanged(0, itemCount) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MediaUnitViewHolder { return MediaUnitViewHolder( ItemEpisodeBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(holder: MediaUnitViewHolder, position: Int) { if (position >= itemCount) return getItem(position)?.let { holder.bind(it) } } inner class MediaUnitViewHolder(private val binding: ItemEpisodeBinding) : RecyclerView.ViewHolder(binding.root) { init { binding.root.setOnClickListener { if (bindingAdapterPosition != RecyclerView.NO_POSITION) { getItem(bindingAdapterPosition)?.let { listener.onMediaUnitClicked(it) } } } binding.checkboxWatched.setOnClickListener { val isChecked = binding.checkboxWatched.isChecked getItem(bindingAdapterPosition)?.let { listener.onWatchStateChanged(it, isChecked) } } } fun bind(unit: MediaUnit) { glide.load(unit.thumbnail?.originalOrDown() ?: posterUrl) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) binding.apply { tvEpisodeTitle.text = unit.title(root.context) tvEpisodeNumber.text = if (unit.hasValidTitle()) { unit.numberText(root.context) } else { null } checkboxWatched.isVisible = enableWatchedCheckbox unit.number?.let { checkboxWatched.isChecked = it <= numberWatched } } } } interface MediaUnitActionListener { fun onMediaUnitClicked(mediaUnit: MediaUnit) fun onWatchStateChanged(mediaUnit: MediaUnit, isWatched: Boolean) } object MediaUnitComparator : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: MediaUnit, newItem: MediaUnit) = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: MediaUnit, newItem: MediaUnit) = oldItem == newItem } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/adapter/paging/ResourceLoadStateAdapter.kt ================================================ package io.github.drumber.kitsune.ui.adapter.paging import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible import androidx.paging.LoadState import androidx.paging.LoadStateAdapter import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import io.github.drumber.kitsune.databinding.ItemNetworkStateBinding class ResourceLoadStateAdapter( private val adapter: PagingDataAdapter, ) : LoadStateAdapter.NetworkStateItemViewHolder>() { override fun onBindViewHolder( holder: NetworkStateItemViewHolder, loadState: LoadState ) { holder.bind(loadState) // support full width for StaggeredGridLayout (holder.itemView.layoutParams as? StaggeredGridLayoutManager.LayoutParams)?.isFullSpan = true } override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState ): NetworkStateItemViewHolder { return NetworkStateItemViewHolder( ItemNetworkStateBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) { adapter.retry() } } inner class NetworkStateItemViewHolder( private val binding: ItemNetworkStateBinding, private val retryCallback: () -> Unit ) : RecyclerView.ViewHolder(binding.root) { init { binding.layoutLoading.btnRetry.setOnClickListener { retryCallback() } } fun bind(loadState: LoadState) { with(binding.layoutLoading) { progressBar.isVisible = loadState is LoadState.Loading btnRetry.isVisible = loadState is LoadState.Error tvError.isVisible = !(loadState as? LoadState.Error)?.error?.message.isNullOrBlank() tvError.text = (loadState as? LoadState.Error)?.error?.message } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/authentication/AuthenticationActivity.kt ================================================ package io.github.drumber.kitsune.ui.authentication import android.app.Activity import android.os.Bundle import android.text.Editable import android.text.TextWatcher import android.text.method.LinkMovementMethod import android.view.inputmethod.EditorInfo import android.widget.Toast import androidx.annotation.StringRes import androidx.core.view.isVisible import com.google.android.material.textfield.TextInputLayout import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.ActivityAuthenticationBinding import io.github.drumber.kitsune.ui.base.BaseActivity import io.github.drumber.kitsune.util.ui.initImePaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import org.koin.androidx.viewmodel.ext.android.viewModel class AuthenticationActivity : BaseActivity() { companion object { const val EXTRA_LOGGED_OUT = "extra_logged_out" } private val viewModel: LoginViewModel by viewModel() private lateinit var binding: ActivityAuthenticationBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityAuthenticationBinding.inflate(layoutInflater) setContentView(binding.root) if (intent.getBooleanExtra(EXTRA_LOGGED_OUT, false)) { showWasLoggedOutInfo() } val username = binding.fieldUsername val password = binding.fieldPassword val login = binding.btnLogin viewModel.loginFormState.observe(this) { loginFormState -> val loginState = loginFormState ?: return@observe // disable login button unless both username / password is valid login.isEnabled = loginState.isDataValid username.error = loginState.usernameError ?.takeIf { username.editText?.isFocused == false } ?.let { getString(it) } } viewModel.loginResult.observe(this) { val loginResult = it ?: return@observe if (loginResult.error != null) { showLoginFailed(loginResult.error) } if (loginResult.success != null) { updateUiWithUser(loginResult.success) setResult(Activity.RESULT_OK) finish() } } viewModel.isLoggingIn.observe(this) { binding.layoutLoading.isVisible = it } username.afterTextChanged { viewModel.loginDataChanged( username.text(), password.text() ) } password.apply { afterTextChanged { viewModel.loginDataChanged( username.text(), password.text() ) } editText?.setOnEditorActionListener { _, actionId, _ -> when (actionId) { EditorInfo.IME_ACTION_DONE -> viewModel.login( username.text(), password.text() ) } false } } login.setOnClickListener { viewModel.login(username.text(), password.text()) } binding.apply { tvCreateAccount.movementMethod = LinkMovementMethod.getInstance() toolbar.initWindowInsetsListener(false) toolbar.setNavigationOnClickListener { setResult(Activity.RESULT_CANCELED) finish() } nsvContent.initPaddingWindowInsetsListener(left = true, right = true, bottom = true, consume = false) root.initImePaddingWindowInsetsListener() } } private fun updateUiWithUser(model: LoggedInUserView) { val displayName = model.displayName Toast.makeText( applicationContext, getString(R.string.logged_in_success, displayName), Toast.LENGTH_LONG ).show() } private fun showLoginFailed(@StringRes errorString: Int) { binding.fieldPassword.error = getString(errorString) Toast.makeText( applicationContext, errorString, Toast.LENGTH_SHORT ).show() } private fun showWasLoggedOutInfo() { binding.tvAdditionalInfo.apply { setText(R.string.info_logged_out_token_expired) isVisible = true } } } /** * Extension function to simplify setting an afterTextChanged action to EditText components. */ fun TextInputLayout.afterTextChanged(afterTextChanged: (String) -> Unit) { this.editText?.addTextChangedListener(object : TextWatcher { override fun afterTextChanged(editable: Editable?) { afterTextChanged.invoke(editable.toString()) } override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {} }) } fun TextInputLayout.text(): String = this.editText!!.text.toString() ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/authentication/LoggedInUserView.kt ================================================ package io.github.drumber.kitsune.ui.authentication /** * User details post authentication that is exposed to the UI */ data class LoggedInUserView( val displayName: String //... other data fields that may be accessible to the UI ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/authentication/LoginFormState.kt ================================================ package io.github.drumber.kitsune.ui.authentication /** * Data validation state of the login form. */ data class LoginFormState( val usernameError: Int? = null, val isDataValid: Boolean = false ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/authentication/LoginResultUi.kt ================================================ package io.github.drumber.kitsune.ui.authentication /** * Authentication result : success (user details) or error message. */ data class LoginResultUi( val success: LoggedInUserView? = null, val error: Int? = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/authentication/LoginViewModel.kt ================================================ package io.github.drumber.kitsune.ui.authentication import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.R import io.github.drumber.kitsune.domain.auth.LogInUserUseCase import io.github.drumber.kitsune.domain.auth.LoginResult import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class LoginViewModel(private val logInUser: LogInUserUseCase) : ViewModel() { private val _loginForm = MutableLiveData() val loginFormState: LiveData = _loginForm private val _loginResult = MutableLiveData() val loginResult: LiveData = _loginResult private val _isLoggingIn = MutableLiveData() val isLoggingIn: LiveData = _isLoggingIn fun login(username: String, password: String) { _isLoggingIn.value = true viewModelScope.launch(Dispatchers.IO) { val result = logInUser(username, password) if(result is LoginResult.Error) { logE("Failed to login to Kitsu.", result.exception) } withContext(Dispatchers.Main) { if (result is LoginResult.Success) { _loginResult.value = LoginResultUi(success = LoggedInUserView(displayName = result.localUser?.name ?: "Unknown")) } else { _loginResult.value = LoginResultUi(error = R.string.login_failed) } _isLoggingIn.value = false } } } fun loginDataChanged(username: String, password: String) { if (!isUserNameValid(username)) { _loginForm.value = LoginFormState(usernameError = R.string.invalid_username) } else if (isPasswordValid(password)) { _loginForm.value = LoginFormState(isDataValid = true) } } private fun isUserNameValid(username: String): Boolean { // verify that username is an email address return ("""^\S+@\S+\.\S+$""".toRegex().matches(username)) } private fun isPasswordValid(password: String): Boolean { return password.isNotBlank() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/base/BaseActivity.kt ================================================ package io.github.drumber.kitsune.ui.base import android.app.ActivityManager import android.content.Context import android.graphics.BitmapFactory import android.os.Build import android.os.Bundle import android.view.Window import androidx.annotation.StyleRes import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import com.chibatching.kotpref.livedata.asLiveData import com.google.android.material.color.DynamicColors import com.google.android.material.elevation.SurfaceColors import io.github.drumber.kitsune.R import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.util.extensions.setStatusBarColorRes abstract class BaseActivity( private val updateSystemUiColors: Boolean = true, private val setAppTheme: Boolean = true ) : AppCompatActivity() { @StyleRes private var appliedThemeRes: Int = -1 private var isDynamicColorsApplied = false override fun onCreate(savedInstanceState: Bundle?) { window.requestFeature(Window.FEATURE_ACTIVITY_TRANSITIONS) if (setAppTheme) { // apply app theme appliedThemeRes = getThemeResFromPreference() setTheme(appliedThemeRes) if (DynamicColors.isDynamicColorAvailable() && KitsunePref.useDynamicColorTheme) { DynamicColors.applyToActivityIfAvailable(this) isDynamicColorsApplied = true } } super.onCreate(savedInstanceState) KitsunePref.asLiveData(KitsunePref::appTheme).observe(this) { checkAppTheme() } KitsunePref.asLiveData(KitsunePref::oledBlackMode).observe(this) { checkAppTheme() } KitsunePref.asLiveData(KitsunePref::useDynamicColorTheme).observe(this) { checkAppTheme() } // set app bar color in recent apps overview setAppTaskColor(SurfaceColors.SURFACE_0.getColor(this)) initEdgeToEdge() } override fun onResume() { super.onResume() checkAppTheme() } private fun checkAppTheme() { if (!setAppTheme) return if (appliedThemeRes != getThemeResFromPreference() || isDynamicColorsApplied != KitsunePref.useDynamicColorTheme ) { recreate() } } private fun getThemeResFromPreference(): Int { val appTheme = KitsunePref.appTheme return if (KitsunePref.oledBlackMode) appTheme.blackThemeRes else appTheme.themeRes } private fun initEdgeToEdge() { WindowCompat.setDecorFitsSystemWindows(window, false) if (!updateSystemUiColors) return setStatusBarColorRes(android.R.color.transparent) if (Build.VERSION.SDK_INT >= 27) { window.navigationBarColor = ContextCompat.getColor(this, android.R.color.transparent) } } /** * Change the color of the app bar that is visible in the recent * app overview. This does only change the color for the current * activity task. * @param color the new color that should be applied */ private fun setAppTaskColor(color: Int) { val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager // find the app task that corresponds to this activity val appTask = activityManager.appTasks.firstOrNull { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { it.taskInfo.taskId == taskId } else { it.taskInfo.id == taskId } } // change the color of the task description, but keep the label and app icon appTask?.taskInfo?.taskDescription?.let { val taskDescription = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> ActivityManager.TaskDescription.Builder() .setPrimaryColor(color) .build() Build.VERSION.SDK_INT >= Build.VERSION_CODES.P -> ActivityManager.TaskDescription( it.label, R.mipmap.ic_launcher, color ) else -> ActivityManager.TaskDescription( it.label, BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher), color ) } setTaskDescription(taskDescription) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/base/BaseDialogFragment.kt ================================================ package io.github.drumber.kitsune.ui.base import android.annotation.SuppressLint import android.graphics.Color import android.util.TypedValue import android.view.ViewGroup import android.view.WindowManager import androidx.annotation.LayoutRes import androidx.appcompat.app.AppCompatDialogFragment import androidx.core.view.WindowCompat import com.google.android.material.color.DynamicColors import io.github.drumber.kitsune.R import io.github.drumber.kitsune.preference.KitsunePref abstract class BaseDialogFragment( @LayoutRes layoutRes: Int, private val isEdgeToEdge: Boolean = true ) : AppCompatDialogFragment(layoutRes) { @SuppressLint("RestrictedApi") override fun onStart() { super.onStart() dialog?.window?.apply { setLayout( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ) addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) attributes?.dimAmount = 0.8f setWindowAnimations(R.style.Theme_Kitsune_Slide) if (isEdgeToEdge) { WindowCompat.setDecorFitsSystemWindows(this, false) statusBarColor = Color.TRANSPARENT navigationBarColor = Color.TRANSPARENT } } } override fun getTheme(): Int { if (KitsunePref.useDynamicColorTheme && DynamicColors.isDynamicColorAvailable()) return R.style.Theme_Kitsune_FullScreenDialog_Dynamic val typedValue = TypedValue() requireActivity().theme.resolveAttribute(R.attr.fullScreenDialogTheme, typedValue, true) return typedValue.data } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/base/BaseFragment.kt ================================================ package io.github.drumber.kitsune.ui.base import androidx.annotation.LayoutRes import androidx.fragment.app.Fragment import io.github.drumber.kitsune.ui.main.FragmentDecorationPreference abstract class BaseFragment( @LayoutRes contentLayoutId: Int, override val hasTransparentStatusBar: Boolean = true ) : Fragment(contentLayoutId), FragmentDecorationPreference ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/base/BasePreferenceFragment.kt ================================================ package io.github.drumber.kitsune.ui.base import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import androidx.annotation.StringRes import androidx.navigation.fragment.findNavController import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.CustomEditTextPreferenceBinding import io.github.drumber.kitsune.databinding.FragmentPreferenceBinding import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener abstract class BasePreferenceFragment( @StringRes private val title: Int = R.string.nav_settings ) : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val binding = FragmentPreferenceBinding.bind(view) binding.apply { collapsingToolbar.initWindowInsetsListener(consume = false) toolbar.initWindowInsetsListener(consume = false) toolbar.setTitle(title) toolbar.setNavigationOnClickListener { findNavController().navigateUp() } } } override fun onCreateRecyclerView( inflater: LayoutInflater, parent: ViewGroup, savedInstanceState: Bundle? ): RecyclerView { val recyclerView = super.onCreateRecyclerView(inflater, parent, savedInstanceState) recyclerView.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) return recyclerView } override fun onDisplayPreferenceDialog(preference: Preference) { // replace old AlertDialog from PreferenceFragmentCompat with new MaterialAlertDialog val builder = MaterialAlertDialogBuilder(requireContext()) .setTitle(preference.title) .setIcon(preference.icon) .setNegativeButton(android.R.string.cancel, null) when (preference) { is EditTextPreference -> { val binding = CustomEditTextPreferenceBinding.inflate(layoutInflater) binding.textInputLayout.editText?.apply { setText(preference.text) requestFocus() post { if (!isAdded) return@post val imm = requireContext() .getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) } } builder .setView(binding.root) .setPositiveButton(android.R.string.ok) { dialog, _ -> val inputText = binding.textInputLayout.editText?.text?.toString() if (preference.callChangeListener(inputText)) { preference.text = inputText } dialog.dismiss() } } is ListPreference -> { val selectedIndex = preference.findIndexOfValue(preference.value) builder.setSingleChoiceItems(preference.entries, selectedIndex) { dialog, index -> val value = preference.entryValues[index].toString() if (preference.callChangeListener(value)) { preference.value = value } dialog.dismiss() } } is MultiSelectListPreference -> { val newValues = preference.values val checkedItems = preference.entryValues.map { entryValue -> newValues.contains(entryValue) }.toBooleanArray() builder .setMultiChoiceItems( preference.entries, checkedItems ) { _, index, isChecked -> val value = preference.entryValues[index].toString() if (isChecked) { newValues.add(value) } else { newValues.remove(value) } } .setPositiveButton(android.R.string.ok) { dialog, _ -> if (preference.callChangeListener(newValues)) { preference.values = newValues } } } } builder.show() } protected fun findPreference(@StringRes preferenceKey: Int): T? { return findPreference(getString(preferenceKey)) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/CustomNumberSpinner.kt ================================================ package io.github.drumber.kitsune.ui.component import android.content.Context import android.util.AttributeSet import android.widget.LinearLayout import androidx.appcompat.widget.TooltipCompat import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.CustomNumberSpinnerBinding typealias ValueChangedListener = (Int) -> Unit class CustomNumberSpinner @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { private val binding: CustomNumberSpinnerBinding private var maxValue: Int = Int.MAX_VALUE private var minValue: Int = 0 private var suffixMode = SuffixMode.Disabled private var customSuffixText: String? = null private var onValueChangedListener: ValueChangedListener? = null private var ignoreTextChangedEvent: Boolean = false init { val view = inflate(context, R.layout.custom_number_spinner, this) binding = CustomNumberSpinnerBinding.bind(view) attrs?.let { val a = context.obtainStyledAttributes(it, R.styleable.CustomNumberSpinner) binding.apply { layoutAction.isVisible = a.getBoolean(R.styleable.CustomNumberSpinner_enableAction, false) btnAction.text = a.getString(R.styleable.CustomNumberSpinner_actionText) setActionTooltip(a.getString(R.styleable.CustomNumberSpinner_actionTooltip)) fieldCount.setText(a.getInt(R.styleable.CustomNumberSpinner_value, 0).toString()) maxValue = a.getInt(R.styleable.CustomNumberSpinner_maxValue, Int.MAX_VALUE) minValue = a.getInt(R.styleable.CustomNumberSpinner_minValue, 0) suffixMode = SuffixMode.entries[a.getInt(R.styleable.CustomNumberSpinner_suffixMode, 0)] tvSuffix.isVisible = suffixMode != SuffixMode.Disabled customSuffixText = a.getString(R.styleable.CustomNumberSpinner_suffixText) updateSuffixText() } a.recycle() } binding.apply { fieldCount.doAfterTextChanged { if (ignoreTextChangedEvent) { return@doAfterTextChanged } val value = it?.toString()?.toIntOrNull()?.coerceIn(minValue..maxValue) ?: minValue onValueChangedListener?.invoke(value) } btnDecrement.setOnClickListener { val value = getValue()?.minus(1) ?: minValue setValue(value) onValueChangedListener?.invoke(value) } btnIncrement.setOnClickListener { val value = (getValue() ?: minValue).plus(1) setValue(value) onValueChangedListener?.invoke(value) } } } private fun updateButtonState() { val value = getValue() ?: minValue binding.apply { btnDecrement.isEnabled = value > minValue btnIncrement.isEnabled = value < maxValue } } private fun updateSuffixText() { binding.tvSuffix.text = if (suffixMode == SuffixMode.MaxValue) { "/ $maxValue" } else { customSuffixText } } fun setValue(value: Int) { ignoreTextChangedEvent = true binding.fieldCount.apply { editableText.clear() append(value.coerceIn(minValue..maxValue).toString()) } ignoreTextChangedEvent = false updateButtonState() } fun getValue() = binding.fieldCount.text?.toString()?.toIntOrNull() fun setValueChangedListener(onValueChanged: ValueChangedListener?) { onValueChangedListener = onValueChanged } fun setMaxValue(maxValue: Int) { this.maxValue = maxValue // make sure value is within new range getValue()?.let { setValue(it) } if (suffixMode == SuffixMode.MaxValue) { updateSuffixText() } } fun getMaxValue() = maxValue fun setMinValue(minValue: Int) { this.minValue = minValue // make sure value is within new range getValue()?.let { setValue(it) } } fun getMinValue() = minValue fun setSuffixMode(suffixMode: SuffixMode) { this.suffixMode = suffixMode binding.tvSuffix.isVisible = this.suffixMode != SuffixMode.Disabled updateSuffixText() } fun getSuffixModel() = suffixMode fun setSuffixCustomText(suffixText: String) { customSuffixText = suffixText if (suffixMode == SuffixMode.CustomText) { updateSuffixText() } } fun setActionEnabled(enableAction: Boolean) { binding.layoutAction.isVisible = enableAction } fun isActionEnabled() = binding.layoutAction.isVisible fun setActionText(actionText: String) { binding.btnAction.text = actionText } fun setActionTooltip(tooltip: String?) { TooltipCompat.setTooltipText(binding.btnAction, tooltip) } fun setActionClickListener(l: OnClickListener?) { binding.btnAction.setOnClickListener(l) } enum class SuffixMode { Disabled, MaxValue, CustomText } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/ExpandableLayout.kt ================================================ package io.github.drumber.kitsune.ui.component import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable import android.util.AttributeSet import android.widget.FrameLayout import androidx.core.animation.addListener import androidx.core.os.bundleOf import androidx.interpolator.view.animation.FastOutSlowInInterpolator import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import io.github.drumber.kitsune.R import io.github.drumber.kitsune.ui.component.ExpandableLayout.State.COLLAPSED import io.github.drumber.kitsune.ui.component.ExpandableLayout.State.COLLAPSING import io.github.drumber.kitsune.ui.component.ExpandableLayout.State.EXPANDED import io.github.drumber.kitsune.ui.component.ExpandableLayout.State.EXPANDING import kotlin.math.round /** * Layout for expanding and collapsing the views height with an optional minimum height. * * Modified version of https://github.com/cachapa/ExpandableLayout */ class ExpandableLayout @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { enum class State { COLLAPSED, COLLAPSING, EXPANDING, EXPANDED } companion object { const val KEY_SUPER_STATE = "super_state" const val KEY_EXPANSION = "expansion" } var duration = 300 private var expansion = 1f var minHeight = 0f set(value) { field = value.coerceAtLeast(0f) } private var state = EXPANDED set(value) { field = value if (_expandedState.value != isExpanded()) { _expandedState.postValue(isExpanded()) } } private val _expandedState = MutableLiveData(isExpanded()) val expandedState get() = _expandedState as LiveData var interpolator = FastOutSlowInInterpolator() private var animator: ValueAnimator? = null init { attrs?.let { val a = context.obtainStyledAttributes(it, R.styleable.ExpandableLayout) duration = a.getInt(R.styleable.ExpandableLayout_duration, duration) expansion = if (a.getBoolean(R.styleable.ExpandableLayout_expanded, true)) 1f else 0f minHeight = a.getDimension(R.styleable.ExpandableLayout_min_height, minHeight) a.recycle() state = if (expansion == 0f && minHeight > 0f) COLLAPSED else EXPANDED } } override fun onSaveInstanceState(): Parcelable { val superState = super.onSaveInstanceState() expansion = if (isExpanded()) 1f else 0f return bundleOf( KEY_EXPANSION to expansion, KEY_SUPER_STATE to superState ) } override fun onRestoreInstanceState(state: Parcelable?) { if (state == null) { return super.onRestoreInstanceState(null) } val bundle = state as Bundle expansion = bundle.getFloat(KEY_EXPANSION) this.state = if (expansion == 1f) EXPANDED else COLLAPSED val superState = bundle.getParcelable(KEY_SUPER_STATE) super.onRestoreInstanceState(superState) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) val width = measuredWidth val height = measuredHeight val minHeight = minHeight.coerceAtMost(height.toFloat()) val calculatedHeight = (minHeight + round((height - minHeight) * expansion)).toInt() setMeasuredDimension(width, calculatedHeight) } override fun onConfigurationChanged(newConfig: Configuration?) { animator?.cancel() super.onConfigurationChanged(newConfig) } fun isExpanded() = state == EXPANDING || state == EXPANDED @JvmOverloads fun toggle(animate: Boolean = true) { if (isExpanded()) { collapse(animate) } else { expand(animate) } } fun expand(animate: Boolean = true) { setExpanded(true, animate) } fun collapse(animate: Boolean = true) { setExpanded(false, animate) } fun setExpanded(expand: Boolean, animate: Boolean = true) { if (expand == isExpanded()) return val targetExpansion = if (expand) 1f else 0f if (animate) { animateSize(targetExpansion) } else { setExpansion(targetExpansion) } } fun getExpansion() = expansion fun setExpansion(expansion: Float) { if (this.expansion == expansion) return val delta = expansion - this.expansion state = when { expansion == 0f -> COLLAPSED expansion == 1f -> EXPANDED delta < 0 -> COLLAPSING else -> EXPANDING } this.expansion = expansion requestLayout() } private fun animateSize(targetExpansion: Float) { animator?.cancel() animator = ValueAnimator.ofFloat(expansion, targetExpansion).apply { interpolator = this@ExpandableLayout.interpolator duration = this@ExpandableLayout.duration.toLong() addUpdateListener { setExpansion(it.animatedValue as Float) } var canceled = false addListener( onStart = { state = if (targetExpansion == 0f) COLLAPSING else EXPANDING }, onEnd = { if (!canceled) { state = if (targetExpansion == 0f) COLLAPSED else EXPANDED } }, onCancel = { canceled = true } ) start() } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/ExploreSection.kt ================================================ package io.github.drumber.kitsune.ui.component import android.content.Context import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.RequestManager import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.SectionMainExploreBinding import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.adapter.MediaRecyclerViewAdapter import io.github.drumber.kitsune.ui.adapter.OnItemClickListener import java.util.concurrent.CopyOnWriteArrayList class ExploreSection( private val glide: RequestManager, private val title: String, private val initialData: List? = null, private val itemListener: OnItemClickListener? = null, private val headerListener: OnHeaderClickListener? = null ) { private lateinit var exploreAdapter: MediaRecyclerViewAdapter fun bindView(view: View) { val binding = SectionMainExploreBinding.bind(view) initView(view.context, binding) } private fun initView(context: Context, binding: SectionMainExploreBinding) { exploreAdapter = MediaRecyclerViewAdapter( if(initialData != null) CopyOnWriteArrayList(initialData) else CopyOnWriteArrayList(), glide, itemSize = KitsunePref.mediaItemSize, transitionNameSuffix = "_$title", listener = itemListener ) binding.apply { rvMedia.apply { layoutManager = LinearLayoutManager(context, RecyclerView.HORIZONTAL, false) adapter = exploreAdapter } tvTitle.text = title header.setOnClickListener { headerListener?.onHeaderClick() } } } fun setData(dataSet: List) { exploreAdapter.dataSet.apply { clear() addAll(dataSet) } exploreAdapter.notifyDataSetChanged() } fun interface OnHeaderClickListener { fun onHeaderClick() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/LayoutResourceLoadingLoadState.kt ================================================ package io.github.drumber.kitsune.ui.component import androidx.core.view.isVisible import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.RecyclerView import io.github.drumber.kitsune.databinding.LayoutResourceLoadingBinding fun LayoutResourceLoadingBinding.updateLoadState( recyclerView: RecyclerView, itemCount: Int, state: CombinedLoadStates, useRemoteMediator: Boolean = false, checkIsNotLoading: () -> Boolean = { state.refresh is LoadState.NotLoading } ) { val remoteState = if (useRemoteMediator) state.mediator else state.source val isNotLoading = checkIsNotLoading() recyclerView.isVisible = isNotLoading root.isVisible = !isNotLoading progressBar.isVisible = state.refresh is LoadState.Loading btnRetry.isVisible = remoteState?.refresh is LoadState.Error tvError.isVisible = remoteState?.refresh is LoadState.Error if (state.refresh is LoadState.NotLoading && state.append.endOfPaginationReached && itemCount < 1 ) { root.isVisible = true tvNoData.isVisible = true recyclerView.isVisible = false } else { tvNoData.isVisible = false } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/LoadStateSpanSizeLookup.kt ================================================ package io.github.drumber.kitsune.ui.component import androidx.paging.LoadState import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView class LoadStateSpanSizeLookup( private val adapter: PagingDataAdapter, private val gridLayoutManager: GridLayoutManager ) : GridLayoutManager.SpanSizeLookup() { private var append: LoadState? = null private var prepend: LoadState? = null init { adapter.addLoadStateListener { append = it.append prepend = it.prepend } } override fun getSpanSize(position: Int): Int { if(position == 0 && prepend !is LoadState.NotLoading) { return gridLayoutManager.spanCount } val totalCount = adapter.itemCount.plus(if(prepend !is LoadState.NotLoading) 1 else 0) if(position >= totalCount && append !is LoadState.NotLoading) { return gridLayoutManager.spanCount } return 1 } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/MediaItemCard.kt ================================================ package io.github.drumber.kitsune.ui.component import android.content.Context import android.util.AttributeSet import android.view.ViewGroup import com.google.android.material.card.MaterialCardView import io.github.drumber.kitsune.constants.MediaItemSize import io.github.drumber.kitsune.preference.KitsunePref import kotlin.math.roundToInt class MediaItemCard @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null ) : MaterialCardView(context, attrs) { var isInGridLayout = false /** Used size if [isInGridLayout] is false */ private var customItemSize = MediaItemSize.SMALL fun setCustomItemSize(customItemSize: MediaItemSize) { this.customItemSize = customItemSize } override fun getLayoutParams(): ViewGroup.LayoutParams { return super.getLayoutParams().apply { if (!isInGridLayout) { width = resources.getDimensionPixelSize(customItemSize.widthRes) height = resources.getDimensionPixelSize(customItemSize.heightRes) } } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { if (!isInGridLayout) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) return } val width = MeasureSpec.getSize(widthMeasureSpec) // original size from preference to calculate the height in the correct aspect ratio val origWidth = resources.getDimensionPixelSize(KitsunePref.mediaItemSize.widthRes) val origHeight = resources.getDimensionPixelSize(KitsunePref.mediaItemSize.heightRes) val newHeight = ((origHeight.toFloat() / origWidth.toFloat()) * width).roundToInt() val newHeightSpec = MeasureSpec.makeMeasureSpec(newHeight, MeasureSpec.EXACTLY) super.onMeasure(widthMeasureSpec, newHeightSpec) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/NestedScrollableHost.kt ================================================ // ViewPager2 nested scroll helper class copied from: // https://github.com/android/views-widgets-samples/blob/f22069a57d5df0b58ce0be08086c3e9db35870b8/ViewPager2/app/src/main/java/androidx/viewpager2/integration/testapp/NestedScrollableHost.kt /* * Copyright 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package io.github.drumber.kitsune.ui.component import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.widget.FrameLayout import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL import kotlin.math.absoluteValue import kotlin.math.sign /** * Layout to wrap a scrollable component inside a ViewPager2. Provided as a solution to the problem * where pages of ViewPager2 have nested scrollable elements that scroll in the same direction as * ViewPager2. The scrollable element needs to be the immediate and only child of this host layout. * * This solution has limitations when using multiple levels of nested scrollable elements * (e.g. a horizontal RecyclerView in a vertical RecyclerView in a horizontal ViewPager2). */ class NestedScrollableHost : FrameLayout { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) private var touchSlop = 0 private var initialX = 0f private var initialY = 0f private val parentViewPager: ViewPager2? get() { var v: View? = parent as? View while (v != null && v !is ViewPager2) { v = v.parent as? View } return v as? ViewPager2 } private val child: View? get() = if (childCount > 0) getChildAt(0) else null init { touchSlop = ViewConfiguration.get(context).scaledTouchSlop } private fun canChildScroll(orientation: Int, delta: Float): Boolean { val direction = -delta.sign.toInt() return when (orientation) { 0 -> child?.canScrollHorizontally(direction) ?: false 1 -> child?.canScrollVertically(direction) ?: false else -> throw IllegalArgumentException() } } override fun onInterceptTouchEvent(e: MotionEvent): Boolean { handleInterceptTouchEvent(e) return super.onInterceptTouchEvent(e) } private fun handleInterceptTouchEvent(e: MotionEvent) { val orientation = parentViewPager?.orientation ?: return // Early return if child can't scroll in same direction as parent if (!canChildScroll(orientation, -1f) && !canChildScroll(orientation, 1f)) { return } if (e.action == MotionEvent.ACTION_DOWN) { initialX = e.x initialY = e.y parent.requestDisallowInterceptTouchEvent(true) } else if (e.action == MotionEvent.ACTION_MOVE) { val dx = e.x - initialX val dy = e.y - initialY val isVpHorizontal = orientation == ORIENTATION_HORIZONTAL // assuming ViewPager2 touch-slop is 2x touch-slop of child val scaledDx = dx.absoluteValue * if (isVpHorizontal) .5f else 1f val scaledDy = dy.absoluteValue * if (isVpHorizontal) 1f else .5f if (scaledDx > touchSlop || scaledDy > touchSlop) { if (isVpHorizontal == (scaledDy > scaledDx)) { // Gesture is perpendicular, allow all parents to intercept parent.requestDisallowInterceptTouchEvent(false) } else { // Gesture is parallel, query child if movement in that direction is possible if (canChildScroll(orientation, if (isVpHorizontal) dx else dy)) { // Child can scroll, disallow all parents to intercept parent.requestDisallowInterceptTouchEvent(true) } else { // Child cannot scroll, allow all parents to intercept parent.requestDisallowInterceptTouchEvent(false) } } } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/PhotoViewNestedScrollView.kt ================================================ package io.github.drumber.kitsune.ui.component import android.content.Context import android.util.AttributeSet import android.view.MotionEvent import androidx.core.widget.NestedScrollView /** * Sets the size of its children equal to its own size and manages scroll interceptions. */ class PhotoViewNestedScrollView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : NestedScrollView(context, attrs, defStyleAttr) { companion object { private const val SCROLL_UP_THRESHOLD = 60f } private var disallowInterceptTouchEvents = false private var startY = -1f override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { super.onMeasure(widthMeasureSpec, heightMeasureSpec) if (!isFillViewport) { return } val heightMode = MeasureSpec.getMode(heightMeasureSpec) if (heightMode == MeasureSpec.UNSPECIFIED) { return } if (childCount > 0) { val child = getChildAt(0) child.measure(widthMeasureSpec, heightMeasureSpec) } } override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { super.requestDisallowInterceptTouchEvent(disallowIntercept) disallowInterceptTouchEvents = disallowIntercept } override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { if (ev.action == MotionEvent.ACTION_DOWN) { startY = ev.y } val isScrollingUp = (startY != -1f && ev.y - startY > SCROLL_UP_THRESHOLD) || ev.action == MotionEvent.ACTION_DOWN // Hauler needs to receive the ACTION_DOWN event if (ev.action == MotionEvent.ACTION_UP) { startY = -1f } return !disallowInterceptTouchEvents && ev.pointerCount == 1 // prevent intercepting pinch-to-zoom gesture && isScrollingUp // only intercept scroll-up actions && super.onInterceptTouchEvent(ev) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/ResponsiveGridLayoutManager.kt ================================================ package io.github.drumber.kitsune.ui.component import android.content.Context import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.max class ResponsiveGridLayoutManager( context: Context, private val columnWidth: Int, private val minColumns: Int = 1, @RecyclerView.Orientation orientation: Int = RecyclerView.VERTICAL, reverseLayout: Boolean = false ) : GridLayoutManager(context, 1, orientation, reverseLayout) { override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?) { val availableWidth = width - paddingRight - paddingLeft spanCount = max(minColumns, availableWidth / columnWidth) super.onLayoutChildren(recycler, state) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/UniqueStateRecyclerView.kt ================================================ package io.github.drumber.kitsune.ui.component import android.content.Context import android.os.Parcelable import android.util.AttributeSet import android.util.SparseArray import android.view.View import androidx.recyclerview.widget.RecyclerView /** * RecyclerView that uses a unique ID to save and restore its state. */ class UniqueStateRecyclerView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = androidx.recyclerview.R.attr.recyclerViewStyle ) : RecyclerView(context, attrs, defStyleAttr) { var uniqueId: Int = View.NO_ID override fun dispatchSaveInstanceState(container: SparseArray) { if (uniqueId == View.NO_ID) { return super.dispatchSaveInstanceState(container) } val stateId = uniqueId xor id val state = onSaveInstanceState() if (state != null) { container.put(stateId, state) } } override fun dispatchRestoreInstanceState(container: SparseArray?) { if (uniqueId == View.NO_ID) { return super.dispatchRestoreInstanceState(container) } val stateId = uniqueId xor id val state = container?.get(stateId) if (state != null) { onRestoreInstanceState(state) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/algolia/SeasonListPresenter.kt ================================================ package io.github.drumber.kitsune.ui.component.algolia import com.algolia.instantsearch.filter.facet.FacetListItem import com.algolia.instantsearch.filter.facet.FacetListPresenter class SeasonListPresenter( private val sortOrder: Array = arrayOf("spring", "summer", "fall", "winter") ) : FacetListPresenter { private val comparator = Comparator { (facetA, _), (facetB, _) -> val indexA = sortOrder.indexOf(facetA.value.lowercase()) val indexB = sortOrder.indexOf(facetB.value.lowercase()) indexA.compareTo(indexB) } override fun invoke(selectableItems: List): List { return selectableItems.sortedWith(comparator) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/algolia/range/CustomFilterRangeConnectionFilterState.kt ================================================ package io.github.drumber.kitsune.ui.component.algolia.range import com.algolia.instantsearch.core.Callback import com.algolia.instantsearch.core.connection.AbstractConnection import com.algolia.instantsearch.core.number.range.Range import com.algolia.instantsearch.filter.range.FilterRangeViewModel import com.algolia.instantsearch.filter.state.FilterGroupID import com.algolia.instantsearch.filter.state.FilterState import com.algolia.instantsearch.filter.state.Filters import com.algolia.instantsearch.filter.state.toFilterNumeric import com.algolia.search.model.Attribute import com.algolia.search.model.filter.Filter data class CustomFilterRangeConnectionFilterState( private val viewModel: FilterRangeViewModel, private val filterState: FilterState, private val attribute: Attribute, private val groupID: FilterGroupID, ) : AbstractConnection() where T : Number, T : Comparable { @Suppress("UNCHECKED_CAST") private val updateRange: Callback = { filters -> val filter = filters.getNumericFilters(groupID) .filter { it.attribute == attribute } .map { it.value } .filterIsInstance() .firstOrNull() if (filter != null) { viewModel.range.value = Range(filter.lowerBound as T, filter.upperBound as T) } else { // set range value to null if the filter is not set viewModel.range.value = null } } private val updateFilterState: Callback?> = { range -> filterState.notify { viewModel.range.value?.let { remove(groupID, it.toFilterNumeric(attribute)) } if (range != null) add(groupID, range.toFilterNumeric(attribute)) } } override fun connect() { super.connect() filterState.filters.subscribePast(updateRange) viewModel.eventRange.subscribe(updateFilterState) } override fun disconnect() { super.disconnect() filterState.filters.unsubscribe(updateRange) viewModel.eventRange.unsubscribe(updateFilterState) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/algolia/range/CustomFilterRangeConnector.kt ================================================ package io.github.drumber.kitsune.ui.component.algolia.range import com.algolia.instantsearch.core.connection.AbstractConnection import com.algolia.instantsearch.core.connection.Connection import com.algolia.instantsearch.core.number.range.Range import com.algolia.instantsearch.filter.range.FilterRangeViewModel import com.algolia.instantsearch.filter.state.FilterGroupID import com.algolia.instantsearch.filter.state.FilterOperator import com.algolia.instantsearch.filter.state.FilterState import com.algolia.search.model.Attribute data class CustomFilterRangeConnector( val viewModel: FilterRangeViewModel, val filterState: FilterState, val attribute: Attribute, val groupID: FilterGroupID = FilterGroupID(attribute, FilterOperator.And), ) : AbstractConnection() where T : Number, T : Comparable { constructor( filterState: FilterState, attribute: Attribute, bounds: ClosedRange? = null, range: ClosedRange? = null, ) : this( FilterRangeViewModel( range = range?.let { Range(it) }, bounds = bounds?.let { Range(it) } ), filterState, attribute ) private val connectionFilterState = viewModel.connectFilterState(filterState, attribute, groupID) override fun connect() { super.connect() connectionFilterState.connect() } override fun disconnect() { super.disconnect() connectionFilterState.disconnect() } } fun FilterRangeViewModel.connectFilterState( filterState: FilterState, attribute: Attribute, groupID: FilterGroupID = FilterGroupID(attribute, FilterOperator.And), ): Connection where T : Number, T : Comparable { return CustomFilterRangeConnectionFilterState(this, filterState, attribute, groupID) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/algolia/range/CustomNumberRangeConnectionView.kt ================================================ package io.github.drumber.kitsune.ui.component.algolia.range import com.algolia.instantsearch.core.Callback import com.algolia.instantsearch.core.connection.AbstractConnection import com.algolia.instantsearch.core.connection.Connection import com.algolia.instantsearch.core.number.range.NumberRangeViewModel import com.algolia.instantsearch.core.number.range.Range data class CustomNumberRangeConnectionView( private val viewModel: NumberRangeViewModel, private val view: CustomNumberRangeView ) : AbstractConnection() where T : Number, T : Comparable { private val updateBounds: Callback?> = { bounds -> view.setBounds(bounds) } private val updateRange: Callback?> = { range -> view.setRange(range) } override fun connect() { super.connect() viewModel.bounds.subscribePast(updateBounds) viewModel.range.subscribePast(updateRange) view.onRangeChanged = (viewModel.eventRange::send) } override fun disconnect() { super.disconnect() viewModel.bounds.unsubscribe(updateBounds) viewModel.range.unsubscribe(updateRange) view.onRangeChanged = null } } fun CustomFilterRangeConnector.connectView( view: CustomNumberRangeView, ): Connection where T : Number, T : Comparable { return CustomNumberRangeConnectionView(viewModel, view) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/algolia/range/CustomNumberRangeView.kt ================================================ package io.github.drumber.kitsune.ui.component.algolia.range import com.algolia.instantsearch.core.Callback import com.algolia.instantsearch.core.number.range.Range interface CustomNumberRangeView where T : Number, T : Comparable { var onRangeChanged: Callback?>? fun setRange(range: Range?) fun setBounds(bounds: Range?) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/algolia/range/IntNumberRangeView.kt ================================================ package io.github.drumber.kitsune.ui.component.algolia.range import com.algolia.instantsearch.core.Callback import com.algolia.instantsearch.core.number.range.Range import com.google.android.material.slider.RangeSlider class IntNumberRangeView( private val slider: RangeSlider ) : CustomNumberRangeView { override var onRangeChanged: Callback?>? = null private var range: Range? = null private var bounds: Range? = null init { slider.stepSize = 1f slider.addOnSliderTouchListener(object : RangeSlider.OnSliderTouchListener { override fun onStartTrackingTouch(slider: RangeSlider) {} override fun onStopTrackingTouch(slider: RangeSlider) { val valueMin = slider.values[0].toInt() val valueMax = slider.values[1].toInt() if (valueMin == bounds?.min && valueMax == bounds?.max) { onRangeChanged?.invoke(null) } else if (range?.min != valueMin || range?.max != valueMax) { onRangeChanged?.invoke(Range(valueMin..valueMax)) } } }) } override fun setRange(range: Range?) { if (range == null) { bounds?.let { slider.setValues(it.min.toFloat(), it.max.toFloat()) } } else if (this.range != range) { slider.setValues(range.min.toFloat(), range.max.toFloat()) } this.range = range } override fun setBounds(bounds: Range?) { bounds?.let { this.bounds = it slider.valueFrom = it.min.toFloat() slider.valueTo = it.max.toFloat() slider.setValues( range?.min?.toFloat() ?: it.min.toFloat(), range?.max?.toFloat() ?: it.max.toFloat() ) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/chart/BarChartStyle.kt ================================================ package io.github.drumber.kitsune.ui.component.chart import android.content.Context import com.github.mikephil.charting.charts.BarChart import com.github.mikephil.charting.components.XAxis import com.github.mikephil.charting.data.BarData import com.github.mikephil.charting.data.BarDataSet import io.github.drumber.kitsune.R import io.github.drumber.kitsune.util.extensions.getColor object BarChartStyle : BaseChartStyle() { fun BarChart.applyStyle( c: Context ) { val theme = c.theme description.isEnabled = false enableScroll() isHighlightPerTapEnabled = true setNoDataTextColor(theme.getColor(R.attr.colorControlNormal)) legend.isEnabled = false axisLeft.isEnabled = false axisRight.isEnabled = false xAxis.apply { position = XAxis.XAxisPosition.BOTTOM textColor = theme.getColor(R.attr.colorOnSurface) setDrawAxisLine(false) setDrawGridLines(false) granularity = 1f } setPinchZoom(false) isDoubleTapToZoomEnabled = false setDrawGridBackground(false) } fun BarDataSet.applyStyle(c: Context, colorArray: List) { valueFormatter = NonZeroLargeValueFormatter() isHighlightEnabled = false applyBaseStyle(c, colorArray) } fun BarData.applyStyle(c: Context) { barWidth = 0.9f applyBaseStyle(c) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/chart/BaseChartStyle.kt ================================================ package io.github.drumber.kitsune.ui.component.chart import android.content.Context import androidx.annotation.ArrayRes import com.github.mikephil.charting.data.BaseDataSet import com.github.mikephil.charting.data.ChartData import io.github.drumber.kitsune.R import io.github.drumber.kitsune.util.extensions.getColor abstract class BaseChartStyle { protected fun BaseDataSet<*>.applyBaseStyle( c: Context, colorArray: List = getColorArray(c, R.array.stats_chart_colors) ) { setDrawIcons(false) colors = colorArray } fun ChartData<*>.applyBaseStyle(c: Context) { setValueTextSize(11f) setValueTextColor(c.theme.getColor(R.attr.colorOnSurface)) } fun getColorArray(c: Context, @ArrayRes colorArray: Int): List { val colors = c.resources.obtainTypedArray(colorArray) val list = mutableListOf() for (i in 0 until colors.length()) { list += colors.getColor(i, 0) } colors.recycle() return list } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/chart/CustomPercentFormatter.kt ================================================ package io.github.drumber.kitsune.ui.component.chart import com.github.mikephil.charting.data.PieEntry import com.github.mikephil.charting.formatter.ValueFormatter import java.text.DecimalFormat class CustomPercentFormatter : ValueFormatter() { private val format = DecimalFormat("##%").apply { multiplier = 1 } override fun getFormattedValue(value: Float): String { return format.format(value) } override fun getPieLabel(value: Float, pieEntry: PieEntry?): String { return getFormattedValue(value) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/chart/NonZeroLargeValueFormatter.kt ================================================ package io.github.drumber.kitsune.ui.component.chart import com.github.mikephil.charting.formatter.LargeValueFormatter class NonZeroLargeValueFormatter : LargeValueFormatter() { override fun getFormattedValue(value: Float): String { return if (value > 0f) { super.getFormattedValue(value) } else { "" } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/chart/PieChartStyle.kt ================================================ package io.github.drumber.kitsune.ui.component.chart import android.content.Context import androidx.annotation.StringRes import com.github.mikephil.charting.animation.Easing import com.github.mikephil.charting.charts.PieChart import com.github.mikephil.charting.components.Legend import com.github.mikephil.charting.data.PieData import com.github.mikephil.charting.data.PieDataSet import io.github.drumber.kitsune.R import io.github.drumber.kitsune.util.extensions.getColor object PieChartStyle : BaseChartStyle() { const val STATS_MAX_ELEMENTS = 7 const val ANIMATION_DURATION = 1000 fun PieChart.applyStyle( c: Context, @StringRes centerTextResId: Int? = null, showLegend: Boolean = false ) { val theme = c.theme setUsePercentValues(false) description.isEnabled = false setExtraOffsets(5f, 5f, 5f, 5f) enableScroll() isRotationEnabled = false isDrawHoleEnabled = true setHoleColor(theme.getColor(android.R.color.transparent)) holeRadius = 55f setTransparentCircleAlpha(50) setCenterTextColor(theme.getColor(R.attr.colorOnSurface)) if (centerTextResId != null) { centerText = c.getString(centerTextResId) } isHighlightPerTapEnabled = true setNoDataTextColor(theme.getColor(R.attr.colorControlNormal)) setEntryLabelColor(theme.getColor(R.attr.colorOnSurface)) animateY(ANIMATION_DURATION, Easing.EaseInOutQuad) legend.apply { form = Legend.LegendForm.CIRCLE verticalAlignment = Legend.LegendVerticalAlignment.BOTTOM horizontalAlignment = Legend.LegendHorizontalAlignment.RIGHT orientation = Legend.LegendOrientation.VERTICAL setDrawInside(false) textColor = theme.getColor(R.attr.colorOnSurface) yEntrySpace = 2f yOffset = 0f xOffset = 0f isEnabled = showLegend } } fun PieDataSet.applyStyle(c: Context) { applyBaseStyle(c) sliceSpace = 0f selectionShift = 5f } fun PieData.applyStyle(c: Context) { applyBaseStyle(c) setValueFormatter(CustomPercentFormatter()) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/component/chart/StepAxisValueFormatter.kt ================================================ package io.github.drumber.kitsune.ui.component.chart import com.github.mikephil.charting.components.AxisBase import com.github.mikephil.charting.formatter.ValueFormatter import java.text.DecimalFormat class StepAxisValueFormatter( private val startValue: Float, private val stepSize: Float ) : ValueFormatter() { override fun getAxisLabel(value: Float, axis: AxisBase?): String { return DecimalFormat("#.##").format(startValue + stepSize * value) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/DetailsFragment.kt ================================================ package io.github.drumber.kitsune.ui.details import android.content.Intent import android.graphics.Color import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.text.SpannableString import android.text.style.ForegroundColorSpan import android.text.style.UnderlineSpan import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.PopupMenu import androidx.core.os.bundleOf import androidx.core.view.children import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.vectordrawable.graphics.drawable.Animatable2Compat import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.github.mikephil.charting.data.BarData import com.github.mikephil.charting.data.BarDataSet import com.github.mikephil.charting.data.BarEntry import com.google.android.material.chip.Chip import com.google.android.material.elevation.SurfaceColors import com.google.android.material.navigation.NavigationBarView import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialContainerTransform import io.github.drumber.kitsune.R import io.github.drumber.kitsune.addTransform import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.constants.SortFilter import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.en import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.common.withoutCommonTitles import io.github.drumber.kitsune.data.presentation.dto.toMedia import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.getStringRes import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.presentation.model.library.getStringResId import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.presentation.model.media.MediaSelector import io.github.drumber.kitsune.data.presentation.model.media.category.Category import io.github.drumber.kitsune.data.source.local.user.model.LocalRatingSystemPreference import io.github.drumber.kitsune.databinding.FragmentDetailsBinding import io.github.drumber.kitsune.databinding.ItemDetailsInfoRowBinding import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.adapter.MediaRelationshipRecyclerViewAdapter import io.github.drumber.kitsune.ui.adapter.StreamingLinkAdapter import io.github.drumber.kitsune.ui.authentication.AuthenticationActivity import io.github.drumber.kitsune.ui.base.BaseFragment import io.github.drumber.kitsune.ui.component.chart.BarChartStyle import io.github.drumber.kitsune.ui.component.chart.BarChartStyle.applyStyle import io.github.drumber.kitsune.ui.component.chart.StepAxisValueFormatter import io.github.drumber.kitsune.ui.details.LibraryChangeResult.AddNewLibraryEntryFailed import io.github.drumber.kitsune.ui.details.LibraryChangeResult.DeleteLibraryEntryFailed import io.github.drumber.kitsune.ui.details.LibraryChangeResult.LibraryUpdateResult import io.github.drumber.kitsune.util.DataUtil.mapLanguageCodesToDisplayName import io.github.drumber.kitsune.util.extensions.getColor import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.extensions.openPhotoViewActivity import io.github.drumber.kitsune.util.extensions.showSomethingWrongToast import io.github.drumber.kitsune.util.extensions.startUrlShareIntent import io.github.drumber.kitsune.util.extensions.toPx import io.github.drumber.kitsune.util.logW import io.github.drumber.kitsune.util.rating.RatingFrequenciesUtil.calculateAverageRating import io.github.drumber.kitsune.util.rating.RatingFrequenciesUtil.transformToRatingSystem import io.github.drumber.kitsune.util.rating.RatingSystemUtil.convertFrom import io.github.drumber.kitsune.util.rating.RatingSystemUtil.stepSize import io.github.drumber.kitsune.util.ui.initMarginWindowInsetsListener import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import io.github.drumber.kitsune.util.ui.showSnackbar import io.github.drumber.kitsune.util.ui.showSnackbarOnFailure import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import java.text.NumberFormat import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.roundToInt class DetailsFragment : BaseFragment(R.layout.fragment_details, true), NavigationBarView.OnItemReselectedListener { private val args: DetailsFragmentArgs by navArgs() private var _binding: FragmentDetailsBinding? = null private val binding get() = _binding!! private val viewModel: DetailsViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val transition = MaterialContainerTransform().apply { drawingViewId = R.id.nav_host_fragment duration = resources.getInteger(R.integer.material_motion_duration_short_2).toLong() scrimColor = Color.TRANSPARENT setAllContainerColors(SurfaceColors.SURFACE_0.getColor(requireContext())) } sharedElementEnterTransition = transition sharedElementReturnTransition = transition } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentDetailsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } if (args.media != null) { viewModel.initMediaModel(args.media!!.toMedia()) } else if (!args.type.isNullOrBlank() && !args.slug.isNullOrBlank()) { val isAnime = when (args.type!!.lowercase()) { "anime" -> true "manga" -> false else -> null } if (isAnime == null) { logW("Unknown media type '${args.type}'.") showSomethingWrongToast() goBack() } else { viewModel.initFromDeepLink(isAnime, args.slug!!) } } else { logW("DetailsFragment opened without media bundle or invalid deeplink parameters.") showSomethingWrongToast() goBack() } initAppBar() viewModel.mediaModel.observe(viewLifecycleOwner) { model -> binding.data = model updateTitlesInDetailsTable(model.titles) showCategoryChips(model) showFranchise(model) showStreamingLinks(model) showRatingChart(model) val glide = Glide.with(this) glide.load(model.coverImageUrl) .transition(DrawableTransitionOptions.withCrossFade()) .placeholder(R.drawable.cover_placeholder) .into(binding.ivCover) glide.load(model.posterImageUrl) .addTransform(RoundedCorners(8.toPx())) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) } binding.ivThumbnail.setOnClickListener { viewModel.mediaModel.value?.let { media -> val title = media.title media.posterImage?.originalOrDown()?.let { imageUrl -> openPhotoViewActivity( imageUrl, title, media.posterImageUrl, binding.ivThumbnail ) } } } binding.ivCover.setOnClickListener { viewModel.mediaModel.value?.let { media -> val title = media.title media.coverImage?.originalOrDown()?.let { imageUrl -> openPhotoViewActivity(imageUrl, title, media.coverImageUrl, binding.ivCover) } } } viewModel.libraryEntryWrapper.observe(viewLifecycleOwner) { libraryEntryWithModification -> val isManga = libraryEntryWithModification?.libraryEntry?.media is Manga || viewModel.mediaModel.value is Manga if (libraryEntryWithModification != null) { libraryEntryWithModification.status?.let { status -> binding.btnManageLibrary.setText(status.getStringResId(!isManga)) } ?: binding.btnManageLibrary.setText(R.string.library_action_add) binding.libraryEntry = libraryEntryWithModification } else { // reset to defaults binding.btnManageLibrary.setText(R.string.library_action_add) binding.libraryEntry = null } } viewModel.favorite.observe(viewLifecycleOwner) { favorite -> val isFavorite = favorite != null updateFavoriteIcon(isFavorite) } viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> binding.progressIndicator.isVisible = isLoading } binding.apply { content.initPaddingWindowInsetsListener(left = true, right = true, bottom = true) btnManageLibrary.setOnClickListener { showManageLibraryBottomSheet() } btnMediaUnits.setOnClickListener { val media = viewModel.mediaModel.value ?: return@setOnClickListener val action = DetailsFragmentDirections.actionDetailsFragmentToEpisodesFragment( media.toMediaDto() ) findNavController().navigate(action) } btnCharacters.setOnClickListener { val media = viewModel.mediaModel.value ?: return@setOnClickListener val action = DetailsFragmentDirections.actionDetailsFragmentToCharactersFragment( media.id, media is Anime ) findNavController().navigate(action) } btnEditLibraryEntry.setOnClickListener { showEditLibraryEntryFragment() } btnRatingTypeMenu.setOnClickListener { v -> showRatingTypeMenu(v) } } viewLifecycleOwner.lifecycleScope.launch { viewModel.libraryChangeResultFlow.collectLatest { when (it) { is LibraryUpdateResult -> it.result.showSnackbarOnFailure(binding.btnManageLibrary) is AddNewLibraryEntryFailed -> showSnackbar( binding.btnManageLibrary, R.string.error_library_add_failed ) is DeleteLibraryEntryFailed -> showSnackbar( binding.btnManageLibrary, R.string.error_library_delete_failed ) } } } setFragmentResultListener(ManageLibraryBottomSheet.STATUS_REQUEST_KEY) { _, bundle -> val libraryEntryStatus = bundle.get(ManageLibraryBottomSheet.BUNDLE_STATUS) as? LibraryStatus libraryEntryStatus?.let { viewModel.updateLibraryEntryStatus(it) } } setFragmentResultListener(ManageLibraryBottomSheet.REMOVE_REQUEST_KEY) { _, bundle -> val shouldRemove = !bundle.getBoolean(ManageLibraryBottomSheet.BUNDLE_EXISTS_IN_LIBRARY) if (shouldRemove) { viewModel.removeLibraryEntry() } } } private fun initAppBar() { binding.apply { toolbar.setNavigationOnClickListener { goBack() } toolbar.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.menu_share_media -> { val url = viewModel.mediaModel.value?.let { val prefix = if (it is Anime) Kitsu.ANIME_URL_PREFIX else Kitsu.MANGA_URL_PREFIX prefix + it.slug } if (url != null) { startUrlShareIntent(url) } else { showSomethingWrongToast() } } R.id.menu_favorite -> { if (viewModel.isLoggedIn()) { // update icon immediately before waiting for response val willBeFavorite = viewModel.favorite.value == null updateFavoriteIcon(willBeFavorite, true) // send update to server viewModel.toggleFavorite() } else { showLogInSnackbar() } } R.id.menu_open_external -> { viewModel.loadMappingsIfNotAlreadyLoaded() val mappingsBottomSheet = MediaMappingsBottomSheet() mappingsBottomSheet.show(childFragmentManager, MediaMappingsBottomSheet.TAG) } } true } collapsingToolbar.initWindowInsetsListener(consume = false) toolbar.initWindowInsetsListener(consume = false) } } private fun updateFavoriteIcon(isFavorite: Boolean, isUserAction: Boolean = false) { val menuItem = binding.toolbar.menu.findItem(R.id.menu_favorite) if (isFavorite && isUserAction) { AnimatedVectorDrawableCompat.create( requireContext(), R.drawable.animated_favorite )?.apply { menuItem.icon = this registerAnimationCallback(object : Animatable2Compat.AnimationCallback() { override fun onAnimationEnd(drawable: Drawable?) { menuItem.setIcon(R.drawable.ic_favorite_24) } }) start() } } else if (menuItem.icon !is AnimatedVectorDrawableCompat || !isFavorite) { menuItem.setIcon( if (isFavorite) R.drawable.ic_favorite_24 else R.drawable.ic_favorite_border_24 ) } menuItem.setTitle( if (isFavorite) R.string.action_remove_from_favorites else R.string.action_add_to_favorites ) } private fun updateTitlesInDetailsTable(titles: Titles?) { val identifierTag = "dynamic_title" val tableLayout = binding.sectionDetailsInfo.tableLayout // remove any previous added titles tableLayout.apply { children.filter { it.tag == identifierTag }.toList().forEach { removeView(it) } } // map language codes and sort them val sortedTitles = titles?.withoutCommonTitles() ?.filterValues { !it.isNullOrBlank() } ?.filterNot { it.key == "en_us" && it.value == titles.en } ?.mapLanguageCodesToDisplayName() ?.toList() ?.sortedByDescending { it.first } if (sortedTitles.isNullOrEmpty()) return val maxShownTitles = 3 val shouldLimitShownTitles = sortedTitles.size > maxShownTitles && !viewModel.areAllTileLanguagesShown val rowIndex = tableLayout.indexOfChild(binding.sectionDetailsInfo.synonymsRowLayout.root) .coerceAtLeast(0) // add a row for each title sortedTitles .takeLast(if (shouldLimitShownTitles) maxShownTitles else Int.MAX_VALUE) .forEach { (language, title) -> val rowBinding = ItemDetailsInfoRowBinding.inflate(layoutInflater) rowBinding.title = language rowBinding.value = title rowBinding.root.tag = identifierTag tableLayout.addView(rowBinding.root, rowIndex) } // add 'show more' text to table if (sortedTitles.size > maxShownTitles) { val showMoreRow = createShowMoreTitlesRow() showMoreRow.tag = identifierTag val viewIndex = tableLayout.indexOfChild(binding.sectionDetailsInfo.synonymsRowLayout.root) .coerceAtLeast(0) tableLayout.addView(showMoreRow, viewIndex) } } private fun createShowMoreTitlesRow(): View { val rowBinding = ItemDetailsInfoRowBinding.inflate(layoutInflater) val text = getString( if (viewModel.areAllTileLanguagesShown) R.string.action_show_less else R.string.action_show_more ) rowBinding.title = SpannableString(text).apply { setSpan( ForegroundColorSpan(requireActivity().theme.getColor(R.attr.colorPrimary)), 0, text.length, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE ) setSpan(UnderlineSpan(), 0, text.length, SpannableString.SPAN_INCLUSIVE_EXCLUSIVE) } rowBinding.tvTitle.setOnClickListener { viewModel.areAllTileLanguagesShown = !viewModel.areAllTileLanguagesShown updateTitlesInDetailsTable(viewModel.mediaModel.value?.titles) } return rowBinding.root } private fun showCategoryChips(media: Media) { if (!media.categories.isNullOrEmpty()) { binding.chipGroupCategories.removeAllViews() media.categories.orEmpty() .sortedBy { it.title } .forEach { category -> val chip = Chip(requireContext()) chip.text = category.title chip.setOnClickListener { onCategoryChipClicked(category, media) } binding.chipGroupCategories.addView(chip) } } } private fun onCategoryChipClicked(category: Category, media: Media) { val categorySlug = category.slug ?: return val title = category.title ?: getString(R.string.no_information) val mediaSelector = MediaSelector( if (media is Anime) MediaType.Anime else MediaType.Manga, Filter() .filter("categories", categorySlug) .sort(SortFilter.POPULARITY_DESC.queryParam) .options ) val action = DetailsFragmentDirections.actionDetailsFragmentToMediaListFragment(mediaSelector, title) findNavController().navigate(action) } private fun showFranchise(media: Media) { val data = media.mediaRelationships?.sortedBy { it.role?.ordinal } ?: emptyList() if (binding.rvFranchise.adapter !is MediaRelationshipRecyclerViewAdapter) { val glide = Glide.with(this) val adapter = MediaRelationshipRecyclerViewAdapter( CopyOnWriteArrayList(data), glide ) { view, clickedMedia -> clickedMedia.media?.let { onFranchiseItemClicked(view, it) } } binding.rvFranchise.adapter = adapter } else { val adapter = binding.rvFranchise.adapter as MediaRelationshipRecyclerViewAdapter adapter.dataSet.addAll(0, data) adapter.notifyDataSetChanged() } } private fun onFranchiseItemClicked(view: View, media: Media) { val action = DetailsFragmentDirections.actionDetailsFragmentSelf(media.toMediaDto()) val detailsTransitionName = getString(R.string.details_poster_transition_name) val extras = FragmentNavigatorExtras(view to detailsTransitionName) findNavController().navigateSafe(R.id.details_fragment, action, extras) } private fun showStreamingLinks(media: Media) { val data = (media as? Anime)?.streamingLinks ?: emptyList() if (binding.rvStreamer.adapter !is StreamingLinkAdapter) { val glide = Glide.with(this) val adapter = StreamingLinkAdapter(CopyOnWriteArrayList(data), glide) { _, streamingLink -> streamingLink.url?.let { url -> val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) startActivity(intent) } } binding.rvStreamer.adapter = adapter } else { val adapter = binding.rvStreamer.adapter as StreamingLinkAdapter adapter.dataSet.addAll(0, data) adapter.notifyDataSetChanged() } } private fun showRatingChart(media: Media) { val ratings = media.ratingFrequencies ?: return val ratingSystem = KitsunePref.ratingChartRatingSystem val ratingList = ratings.transformToRatingSystem(ratingSystem) val chartEntries = ratingList.mapIndexed { index, value -> BarEntry(index.toFloat(), value.toFloat()) } val dataSet = BarDataSet(chartEntries, "Ratings") val chartColorArray = BarChartStyle .getColorArray(requireContext(), R.array.ratings_chart_colors) .let { colorArray -> val colorStep = (colorArray.size.toFloat() / ratingList.size).roundToInt() colorArray.filterIndexed { index, _ -> index % colorStep == 0 } } dataSet.applyStyle(requireContext(), chartColorArray) val barData = BarData(dataSet) barData.applyStyle(requireContext()) binding.chartRatings.apply { data = barData applyStyle(requireContext()) setFitBars(true) xAxis.valueFormatter = StepAxisValueFormatter( ratingSystem.convertFrom(2), ratingSystem.stepSize() ) xAxis.labelCount = ratingList.size invalidate() } val avgRating = ratings.calculateAverageRating(ratingSystem) val numberFormatter = NumberFormat.getNumberInstance() numberFormatter.minimumFractionDigits = 1 numberFormatter.maximumFractionDigits = 2 binding.tvCalculatedAverageRating.text = numberFormatter.format(avgRating) binding.tvCalculatedAverageRatingMax.text = "/ " + numberFormatter.format(ratingSystem.convertFrom(20)) } private fun showRatingTypeMenu(anchorView: View) { val popup = PopupMenu(requireContext(), anchorView) val menu = popup.menu val selectedRatingSystem = KitsunePref.ratingChartRatingSystem LocalRatingSystemPreference.entries.forEach { val menuItem = menu.add(1, it.ordinal, it.ordinal, it.getStringRes()) menuItem.isChecked = selectedRatingSystem == it menuItem.setOnMenuItemClickListener { _ -> KitsunePref.ratingChartRatingSystem = it viewModel.mediaModel.value?.let { mediaAdapter -> showRatingChart(mediaAdapter) } true } } menu.setGroupCheckable(1, true, true) popup.show() } private fun showManageLibraryBottomSheet() { if (viewModel.isLoggedIn()) { viewModel.mediaModel.value?.let { mediaAdapter -> val sheetManageLibrary = ManageLibraryBottomSheet() val bundle = bundleOf( ManageLibraryBottomSheet.BUNDLE_TITLE to mediaAdapter.title, ManageLibraryBottomSheet.BUNDLE_IS_ANIME to (mediaAdapter is Anime), ManageLibraryBottomSheet.BUNDLE_EXISTS_IN_LIBRARY to (viewModel.libraryEntryWrapper.value != null) ) sheetManageLibrary.arguments = bundle sheetManageLibrary.show(parentFragmentManager, ManageLibraryBottomSheet.TAG) } } else { showLogInSnackbar() } } private fun showEditLibraryEntryFragment() { if (!viewModel.isLoggedIn()) return val entryId = viewModel.libraryEntryWrapper.value?.libraryEntry?.id ?: return val action = DetailsFragmentDirections.actionDetailsFragmentToLibraryEditEntryFragment(entryId) findNavController().navigateSafe(R.id.details_fragment, action) } private fun showLogInSnackbar() { Snackbar.make( binding.btnManageLibrary, R.string.info_log_in_required, Snackbar.LENGTH_LONG ).apply { view.initMarginWindowInsetsListener(left = true, right = true, bottom = true) setAction(R.string.action_log_in) { val intent = Intent(requireActivity(), AuthenticationActivity::class.java) startActivity(intent) } }.show() } private fun goBack() { findNavController().navigateUp() } override fun onNavigationItemReselected(item: MenuItem) { if (binding.nsvContent.canScrollVertically(-1)) { binding.nsvContent.smoothScrollTo(0, 0) binding.appBarLayout.setExpanded(true) } else { goBack() } } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/DetailsViewModel.kt ================================================ package io.github.drumber.kitsune.ui.details import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.common.exception.ResourceUpdateFailed import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.presentation.model.mapping.Mapping import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.presentation.model.user.Favorite import io.github.drumber.kitsune.data.repository.AnimeRepository import io.github.drumber.kitsune.data.repository.FavoriteRepository import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.data.repository.MangaRepository import io.github.drumber.kitsune.data.repository.MappingRepository import io.github.drumber.kitsune.domain.auth.IsUserLoggedInUseCase import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryUseCase import io.github.drumber.kitsune.domain.user.GetLocalUserIdUseCase import io.github.drumber.kitsune.ui.details.LibraryChangeResult.AddNewLibraryEntryFailed import io.github.drumber.kitsune.ui.details.LibraryChangeResult.DeleteLibraryEntryFailed import io.github.drumber.kitsune.ui.details.LibraryChangeResult.LibraryUpdateResult import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.logW import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class DetailsViewModel( private val getLocalUserId: GetLocalUserIdUseCase, private val isUserLoggedIn: IsUserLoggedInUseCase, private val updateLibraryEntry: UpdateLibraryEntryUseCase, private val favoriteRepository: FavoriteRepository, private val libraryRepository: LibraryRepository, private val animeRepository: AnimeRepository, private val mangaRepository: MangaRepository, private val mappingRepository: MappingRepository ) : ViewModel() { fun isLoggedIn() = isUserLoggedIn() private val _mediaModel = MutableLiveData() val mediaModel: LiveData get() = _mediaModel /** Combines local cached and fetched library entry. */ private val _libraryEntryWithModification = MediatorLiveData() val libraryEntryWrapper: LiveData get() = _libraryEntryWithModification private val _favorite = MutableLiveData() val favorite: LiveData get() = _favorite private val _mappingsSate = MutableStateFlow(MediaMappingsSate.Initial) val mappingsSate get() = _mappingsSate.asStateFlow() private val _isLoading = MutableLiveData(false) val isLoading: LiveData get() = _isLoading private val acceptInternalAction: (InternalAction) -> Unit val libraryChangeResultFlow: Flow var areAllTileLanguagesShown = false init { val internalActionFlow = MutableSharedFlow() libraryChangeResultFlow = internalActionFlow.mapNotNull { action -> when (action) { is InternalAction.LibraryUpdateResult -> LibraryUpdateResult(action.result) is InternalAction.AddNewLibraryEntryFailed -> AddNewLibraryEntryFailed is InternalAction.DeleteLibraryEntryFailed -> DeleteLibraryEntryFailed } } acceptInternalAction = { action -> viewModelScope.launch { internalActionFlow.emit(action) } } } fun initFromDeepLink(isAnime: Boolean, slug: String) { val filter = Filter() .filter("slug", slug) .fields("media", "id") viewModelScope.launch(Dispatchers.IO) { val media = try { if (isAnime) { animeRepository.getAllAnime(filter) } else { mangaRepository.getAllManga(filter) } } catch (e: Exception) { logE("Failed to load media from deep link.", e) null } if (media.isNullOrEmpty()) { logW("No media for slug '$slug' found.") return@launch } withContext(Dispatchers.Main) { initMediaModel(media.first()) } } } fun initMediaModel(media: Media) { if (_mediaModel.value == null) { _mediaModel.value = media _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { libraryRepository.getLibraryEntryFromMedia(media.id)?.media?.let { // display media model from local library entry if available _mediaModel.postValue(it) } awaitAll( async { loadFullMedia(media) }, async { loadLibraryEntry(media) }, async { loadFavorite(media) } ) _isLoading.postValue(false) } } } private suspend fun loadFullMedia(media: Media) { val id = media.id val filter = Filter() .fields("categories", "slug", "title") val commonIncludes = arrayOf( "categories", "mediaRelationships", "mediaRelationships.destination" ) try { val fullMedia = if (media is Anime) { filter.include( *commonIncludes, "animeProductions.producer", "streamingLinks", "streamingLinks.streamer" ) animeRepository.getAnime(id, filter) } else { filter.include(*commonIncludes) mangaRepository.getManga(id, filter) } ?: throw NoDataException("Received data is null.") _mediaModel.postValue(fullMedia) } catch (e: Exception) { logE("Failed to load full media model.", e) } } private suspend fun loadLibraryEntry(media: Media) { val userId = getLocalUserId() ?: return // add local database as library entry source viewModelScope.launch(Dispatchers.Main) { _libraryEntryWithModification.addSource(libraryRepository.getLibraryEntryWithModificationFromMediaAsLiveData(media.id)) { _libraryEntryWithModification.value = it } } val filter = Filter() .filter("user_id", userId) .fields("libraryEntries", "status", "progress", "ratingTwenty") .pageLimit(1) if (media is Anime) { filter.filter("anime_id", media.id) } else { filter.filter("manga_id", media.id) } try { // fetch library entry from the server val libraryEntries = libraryRepository.fetchAllLibraryEntries(filter) if (!libraryEntries.isNullOrEmpty()) { // post fetched library entry that is possibly more up-to-date than the local cached one _libraryEntryWithModification.postValue( LibraryEntryWithModification(libraryEntries.first(), null) ) } else if (libraryEntryWrapper.value != null) { // library entry is not available on the server but it is in the local cache, was it deleted? // -> local database cache is out of sync, remove entry from database libraryEntryWrapper.value?.let { wrapper -> logD( "There is no library entry on the server, but it exists in the local cache. " + "Removed it from local database..." ) withContext(Dispatchers.IO) { _libraryEntryWithModification.postValue(null) libraryRepository.mayRemoveLibraryEntryLocally(wrapper.libraryEntry.id) } } } } catch (e: Exception) { logE("Failed to load library entry.", e) } } private suspend fun loadFavorite(media: Media) { val userId = getLocalUserId() ?: return val filter = Filter() .filter("user_id", userId) .filter("item_id", media.id) .filter("item_type", if (media is Anime) "Anime" else "Manga") try { val favorites = favoriteRepository.getAllFavorites(filter) _favorite.postValue(favorites?.firstOrNull()) } catch (e: Exception) { logE("Failed to load favorites.", e) } } fun loadMappingsIfNotAlreadyLoaded() { if (_mappingsSate.value != MediaMappingsSate.Initial) return val mediaModel = mediaModel.value ?: return viewModelScope.launch(Dispatchers.IO) { _mappingsSate.value = MediaMappingsSate.Loading val mappingsState = try { val mappings = if (mediaModel is Anime) { mappingRepository.getAnimeMappings(mediaModel.id) } else { mappingRepository.getMangaMappings(mediaModel.id) } ?: emptyList() val mappingsWithKitsu = mappings + Mapping( id = "", externalSite = "kitsu/" + if (mediaModel is Anime) "anime" else "manga", externalId = mediaModel.slug ?: mediaModel.id ) MediaMappingsSate.Success(mappingsWithKitsu) } catch (e: Exception) { logE("Failed to load mappings.", e) MediaMappingsSate.Error(e.message ?: "Failed to load mappings.") } _mappingsSate.value = mappingsState } } fun updateLibraryEntryStatus(status: LibraryStatus) { val userId = getLocalUserId() ?: return val mediaModel = mediaModel.value ?: return val existingLibraryEntryId = libraryEntryWrapper.value?.libraryEntry?.id viewModelScope.launch(Dispatchers.IO) { if (existingLibraryEntryId.isNullOrBlank()) { // post new library entry try { val newLibraryEntry = libraryRepository.addNewLibraryEntry( userId, mediaModel, status ) ?: throw NoDataException("Failed to post new library entry.") _libraryEntryWithModification.postValue( LibraryEntryWithModification(newLibraryEntry, null) ) } catch (e: Exception) { logE("Failed to add new library entry.", e) acceptInternalAction(InternalAction.AddNewLibraryEntryFailed) } } else { // update existing library entry val modification = LibraryEntryModification .withIdAndNulls(existingLibraryEntryId) .copy(status = status) val result = updateLibraryEntry(modification) acceptInternalAction(InternalAction.LibraryUpdateResult(result)) } } } fun removeLibraryEntry() { val libraryEntryId = libraryEntryWrapper.value?.libraryEntry?.id ?: return viewModelScope.launch(Dispatchers.IO) { val isDeleted = try { libraryRepository.removeLibraryEntry(libraryEntryId) true } catch (e: Exception) { logE("Failed to remove library entry.", e) false } if (isDeleted) { _libraryEntryWithModification.postValue(null) } else { acceptInternalAction(InternalAction.DeleteLibraryEntryFailed) } } } fun toggleFavorite() { val favorite = favorite.value viewModelScope.launch(Dispatchers.IO) { if (favorite == null) { val mediaItem = mediaModel.value ?: return@launch val userId = getLocalUserId() ?: return@launch try { val newFavorite = favoriteRepository.createMediaFavorite(userId, mediaItem.mediaType, mediaItem.id) _favorite.postValue(newFavorite) } catch (e: Exception) { logE("Failed to create new favorite.", e) } } else { val favoriteId = favorite.id try { val isSuccessful = favoriteRepository.deleteFavorite(favoriteId) if (isSuccessful) { _favorite.postValue(null) } else { throw ResourceUpdateFailed() } } catch (e: Exception) { logE("Failed to delete favorite.", e) } } } } } sealed class LibraryChangeResult { data class LibraryUpdateResult(val result: LibraryEntryUpdateResult) : LibraryChangeResult() data object AddNewLibraryEntryFailed : LibraryChangeResult() data object DeleteLibraryEntryFailed : LibraryChangeResult() } private sealed class InternalAction { data class LibraryUpdateResult(val result: LibraryEntryUpdateResult) : InternalAction() data object AddNewLibraryEntryFailed : InternalAction() data object DeleteLibraryEntryFailed : InternalAction() } sealed class MediaMappingsSate { data object Initial : MediaMappingsSate() data object Loading : MediaMappingsSate() data class Success(val mappings: List) : MediaMappingsSate() data class Error(val message: String) : MediaMappingsSate() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/ManageLibraryBottomSheet.kt ================================================ package io.github.drumber.kitsune.ui.details import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.setFragmentResult import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.databinding.SheetManageLibraryBinding class ManageLibraryBottomSheet : BottomSheetDialogFragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val binding = SheetManageLibraryBinding.inflate(inflater, container, false) binding.apply { instance = this@ManageLibraryBottomSheet title = arguments?.getString(BUNDLE_TITLE) } return binding.root } fun onStatusClicked(status: LibraryStatus) { setFragmentResult(STATUS_REQUEST_KEY, bundleOf(BUNDLE_STATUS to status)) dismiss() } fun onRemoveClicked() { setFragmentResult(REMOVE_REQUEST_KEY, bundleOf(BUNDLE_EXISTS_IN_LIBRARY to false)) dismiss() } fun existsInLibrary(): Boolean { return arguments?.getBoolean(BUNDLE_EXISTS_IN_LIBRARY) ?: false } fun isAnime() = arguments?.getBoolean(BUNDLE_IS_ANIME) == true companion object { const val TAG = "manage_library_bottom_sheet" const val BUNDLE_TITLE = "title_bundle_key" const val BUNDLE_STATUS = "status_bundle_key" const val BUNDLE_EXISTS_IN_LIBRARY = "status_exists_in_library" const val BUNDLE_IS_ANIME = "is_anime_key" const val STATUS_REQUEST_KEY = "status_request_key" const val REMOVE_REQUEST_KEY = "remove_request_key" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/MediaMappingsBottomSheet.kt ================================================ package io.github.drumber.kitsune.ui.details import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.github.drumber.kitsune.data.presentation.model.mapping.getExternalUrl import io.github.drumber.kitsune.databinding.SheetMediaMappingsBinding import io.github.drumber.kitsune.ui.adapter.MediaMappingsAdapter import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class MediaMappingsBottomSheet : BottomSheetDialogFragment() { private var _binding: SheetMediaMappingsBinding? = null private val binding get() = _binding!! private val viewModel: DetailsViewModel by viewModel(ownerProducer = { requireParentFragment() }) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = SheetMediaMappingsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val adapter = MediaMappingsAdapter(requireContext(), mutableListOf()) binding.listMediaMappings.adapter = adapter viewLifecycleOwner.lifecycleScope.launch { viewModel.mappingsSate.collectLatest { state -> binding.progressBarMediaMappings.isVisible = state is MediaMappingsSate.Loading binding.tvErrorMediaMappings.isVisible = state is MediaMappingsSate.Error binding.listMediaMappings.isVisible = state is MediaMappingsSate.Success if (state is MediaMappingsSate.Success) { val mappings = state.mappings .distinctBy { it.getExternalUrl() ?: it.externalSite } .sortedBy { it.externalSite } adapter.dataSource.clear() adapter.dataSource.addAll(mappings) adapter.notifyDataSetChanged() } } } } override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val TAG = "media_mappings_bottom_sheet" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/characters/CharacterDetailsBottomSheet.kt ================================================ package io.github.drumber.kitsune.ui.details.characters import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.TooltipCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.vectordrawable.graphics.drawable.Animatable2Compat.AnimationCallback import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.github.drumber.kitsune.R import io.github.drumber.kitsune.addTransform import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.presentation.dto.toCharacter import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.presentation.model.character.MediaCharacter import io.github.drumber.kitsune.databinding.ItemDetailsInfoRowBinding import io.github.drumber.kitsune.databinding.SheetCharacterDetailsBinding import io.github.drumber.kitsune.ui.adapter.MediaCharacterAdapter import io.github.drumber.kitsune.util.DataUtil.mapLanguageCodesToDisplayName import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.extensions.openCharacterOnMAL import io.github.drumber.kitsune.util.extensions.openPhotoViewActivity import io.github.drumber.kitsune.util.extensions.toPx import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import java.util.concurrent.CopyOnWriteArrayList class CharacterDetailsBottomSheet : BottomSheetDialogFragment() { private var _binding: SheetCharacterDetailsBinding? = null private val binding get() = _binding!! private val viewModel: CharacterDetailsViewModel by viewModel() private val navArgs by navArgs() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = SheetCharacterDetailsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.rvMediaCharacters.adapter = MediaCharacterAdapter( CopyOnWriteArrayList(), Glide.with(this) ) { _, mediaCharacter -> val media = mediaCharacter.media ?: return@MediaCharacterAdapter val action = CharacterDetailsBottomSheetDirections.actionCharacterDetailsBottomSheetToDetailsFragment( media.toMediaDto() ) findNavController().navigateSafe(R.id.characterDetailsBottomSheet, action) } viewModel.initCharacter(navArgs.character.toCharacter()) viewLifecycleOwner.lifecycleScope.launch { viewModel.characterFlow.collectLatest { character -> updateCharacterData(character) } } viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collectLatest { state -> val isLoading = state.isLoadingMediaCharacters binding.progressBar.isVisible = isLoading binding.loadingWrapper.isVisible = isLoading || !state.hasMediaCharacters binding.tvNoData.isVisible = !isLoading && !state.hasMediaCharacters binding.rvMediaCharacters.isVisible = !isLoading && state.hasMediaCharacters } } viewLifecycleOwner.lifecycleScope.launch { viewModel.favoriteFlow.collectLatest { favorite -> val icon = binding.btnFavorite.icon if (favorite != null && icon is AnimatedVectorDrawableCompat) { icon.registerAnimationCallback(object : AnimationCallback() { override fun onAnimationEnd(drawable: Drawable?) { // binding can be null if the fragment is destroyed _binding?.btnFavorite?.setIconResource(R.drawable.ic_favorite_24) } }) } else { binding.btnFavorite.setIconResource( if (favorite != null) R.drawable.ic_favorite_24 else R.drawable.ic_favorite_border_24 ) } TooltipCompat.setTooltipText( binding.btnFavorite, getString( if (favorite != null) R.string.action_remove_from_favorites else R.string.action_add_to_favorites ) ) } } binding.ivCharacter.setOnClickListener { val fullCharacter = viewModel.characterFlow.replayCache.lastOrNull() ?: return@setOnClickListener fullCharacter.image?.originalOrDown()?.let { imageUrl -> openPhotoViewActivity( imageUrl, fullCharacter.name, fullCharacter.image.smallOrHigher(), binding.ivCharacter ) } } binding.btnOpenOnMal.setOnClickListener { viewModel.characterFlow.replayCache.lastOrNull()?.malId?.let { malId -> openCharacterOnMAL(malId) } } binding.btnFavorite.setOnClickListener { val addToFavorite = viewModel.toggleFavorite() if (addToFavorite) { AnimatedVectorDrawableCompat.create(requireContext(), R.drawable.animated_favorite) ?.apply { binding.btnFavorite.icon = this registerAnimationCallback(object : AnimationCallback() { var originalTintColor = binding.btnFavorite.iconTint override fun onAnimationStart(drawable: Drawable?) { drawable?.setTintList(null) } override fun onAnimationEnd(drawable: Drawable?) { drawable?.setTintList(originalTintColor) } }) start() } } findNavController().previousBackStackEntry ?.takeIf { it.destination.id == R.id.profile_fragment } ?.savedStateHandle ?.set("refreshFavorites", true) } } private fun updateCharacterData(character: Character) { binding.character = character updateNamesInTable(character.names) updateMediaCharactersRecyclerView(character.mediaCharacters) binding.btnOpenOnMal.isVisible = character.malId != null Glide.with(this) .load(character.image?.originalOrDown()) .fitCenter() .addTransform(RoundedCorners(8.toPx())) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivCharacter) } private fun updateNamesInTable(names: Titles?) { binding.tableNames.removeViews(0, binding.tableNames.childCount - 1) val sortedNames = names?.filterValues { !it.isNullOrBlank() } ?.mapLanguageCodesToDisplayName(false) ?.toList() ?.sortedByDescending { it.first } sortedNames?.forEach { (language, name) -> val rowBinding = ItemDetailsInfoRowBinding.inflate(layoutInflater) rowBinding.title = language rowBinding.value = name binding.tableNames.addView(rowBinding.root, 0) } } private fun updateMediaCharactersRecyclerView(mediaCharacters: List?) { (binding.rvMediaCharacters.adapter as MediaCharacterAdapter).apply { dataSet.clear() val sortedMediaCharacters = mediaCharacters?.sortedBy { it.role?.ordinal } ?: emptyList() dataSet.addAll(sortedMediaCharacters) notifyDataSetChanged() } } override fun onDestroy() { super.onDestroy() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/characters/CharacterDetailsViewModel.kt ================================================ package io.github.drumber.kitsune.ui.details.characters import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.constants.Defaults import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.presentation.model.user.Favorite import io.github.drumber.kitsune.data.repository.CharacterRepository import io.github.drumber.kitsune.data.repository.FavoriteRepository import io.github.drumber.kitsune.domain.user.GetLocalUserIdUseCase import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class CharacterDetailsViewModel( private val characterRepository: CharacterRepository, private val getLocalUserId: GetLocalUserIdUseCase, private val favoriteRepository: FavoriteRepository ) : ViewModel() { private val _characterFlow = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val characterFlow get() = _characterFlow.asSharedFlow() private val _favoriteFlow = MutableSharedFlow( replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST ) val favoriteFlow get() = _favoriteFlow.asSharedFlow() private val _uiState = MutableStateFlow(UiState()) val uiState get() = _uiState.asStateFlow() fun initCharacter(character: Character) { if (_characterFlow.replayCache.isNotEmpty() && _characterFlow.replayCache.firstOrNull()?.id == character.id) return viewModelScope.launch { _characterFlow.emit(character) launch(Dispatchers.IO) fetchFavorite@{ val characterId = character.id val favorite = fetchFavorite(characterId) _favoriteFlow.emit(favorite) } launch(Dispatchers.IO) fetchFullCharacter@{ // fetch full character model val characterId = character.id _uiState.emit(UiState(isLoadingMediaCharacters = true)) val fullCharacter = try { fetchCharacterData(characterId) } finally { _uiState.emit(UiState(isLoadingMediaCharacters = false)) } if (fullCharacter != null) { _characterFlow.emit(fullCharacter) } _uiState.emit(UiState(hasMediaCharacters = fullCharacter?.mediaCharacters?.isNotEmpty() == true)) } } } private suspend fun fetchCharacterData(id: String): Character? { val filter = Filter() .include("mediaCharacters", "mediaCharacters.media") .fields("media", *Defaults.MINIMUM_COLLECTION_FIELDS) return try { characterRepository.getCharacter(id, filter) } catch (e: Exception) { logE("Failed to fetch character data.", e) null } } private suspend fun fetchFavorite(characterId: String): Favorite? { val userId = getLocalUserId() ?: return null val filter = Filter() .filter("user_id", userId) .filter("item_id", characterId) .filter("item_type", "Character") return try { favoriteRepository.getAllFavorites(filter)?.firstOrNull() } catch (e: Exception) { logE("Failed to fetch favorites.", e) null } } fun toggleFavorite(): Boolean { val characterId = characterFlow.replayCache.firstOrNull()?.id ?: return false val userId = getLocalUserId() ?: return false val favorite = favoriteFlow.replayCache.firstOrNull() viewModelScope.launch(Dispatchers.IO) { if (favorite == null) { val addedFavorite = addToFavorites(userId, characterId) ?: return@launch _favoriteFlow.emit(null) _favoriteFlow.emit(addedFavorite) } else { val favoriteId = favorite.id if (removeFromFavorites(favoriteId)) { _favoriteFlow.emit(null) } } } return favorite == null } private suspend fun addToFavorites(userId: String, characterId: String): Favorite? { return try { favoriteRepository.createCharacterFavorite(userId, characterId) } catch (e: Exception) { logE("Failed to post favorite.", e) null } } private suspend fun removeFromFavorites(favoriteId: String): Boolean { return try { favoriteRepository.deleteFavorite(favoriteId) } catch (e: Exception) { logE("Failed to delete favorite.", e) false } } } data class UiState( val isLoadingMediaCharacters: Boolean = false, val hasMediaCharacters: Boolean = false ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/characters/CharacterFilterAdapter.kt ================================================ package io.github.drumber.kitsune.ui.details.characters import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.github.drumber.kitsune.databinding.ItemCharacterFilterBinding class CharacterFilterAdapter( isViewHolderVisible: Boolean, var languages: List = emptyList(), var selectedLanguage: String? = null, private val onItemClicked: (language: String) -> Unit ) : RecyclerView.Adapter() { var isViewHolderVisible = isViewHolderVisible set(value) { val previous = field field = value when { previous != value && value -> notifyItemInserted(0) previous != value && !value -> notifyItemRemoved(0) } } fun notifyItemChanged() = notifyItemChanged(0) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CharacterFilterViewHolder { return CharacterFilterViewHolder( ItemCharacterFilterBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindViewHolder(holder: CharacterFilterViewHolder, position: Int) { holder.setLanguages(languages) holder.setSelectedLanguage(selectedLanguage) } override fun getItemCount() = if (isViewHolderVisible) 1 else 0 inner class CharacterFilterViewHolder(val binding: ItemCharacterFilterBinding) : RecyclerView.ViewHolder(binding.root) { init { binding.chipLanguage.setOnClickListener { val languages = languages val checkedItemIndex = languages.indexOf(selectedLanguage) MaterialAlertDialogBuilder(binding.root.context) .setSingleChoiceItems(languages.toTypedArray(), checkedItemIndex) { dialog, i -> if (i == checkedItemIndex) { dialog.dismiss() return@setSingleChoiceItems } val checkedLanguage = languages[i] setSelectedLanguage(checkedLanguage) selectedLanguage = checkedLanguage onItemClicked(checkedLanguage) dialog.dismiss() } .show() } setLanguages(languages) setSelectedLanguage(selectedLanguage) } fun setLanguages(languages: List) { binding.chipLanguage.apply { if (!languages.contains(text)) { text = languages.firstOrNull() } } } fun setSelectedLanguage(language: String?) { binding.chipLanguage.text = language } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/characters/CharactersFragment.kt ================================================ package io.github.drumber.kitsune.ui.details.characters import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.google.android.material.navigation.NavigationBarView import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.dto.toCharacterDto import io.github.drumber.kitsune.databinding.FragmentCharactersBinding import io.github.drumber.kitsune.ui.adapter.paging.CharacterPagingAdapter import io.github.drumber.kitsune.ui.adapter.paging.ResourceLoadStateAdapter import io.github.drumber.kitsune.ui.component.updateLoadState import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class CharactersFragment : Fragment(R.layout.fragment_characters), NavigationBarView.OnItemReselectedListener { private val args: CharactersFragmentArgs by navArgs() private var _binding: FragmentCharactersBinding? = null private val binding get() = _binding!! private val viewModel: CharactersViewModel by viewModel() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentCharactersBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.setMediaId(args.mediaId, args.isAnime) binding.apply { collapsingToolbar.initWindowInsetsListener(consume = false) toolbar.initWindowInsetsListener(false) toolbar.setNavigationOnClickListener { findNavController().navigateUp() } rvMedia.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) } binding.layoutLoading.btnRetry.setOnClickListener { viewModel.retry(args.mediaId, args.isAnime) } val filterAdapter = CharacterFilterAdapter(false) { language -> viewModel.setLanguage(language) } val pagingAdapter = CharacterPagingAdapter(Glide.with(this)) { _, character -> val action = CharactersFragmentDirections .actionCharactersFragmentToCharacterDetailsBottomSheet(character.toCharacterDto()) findNavController().navigateSafe(R.id.characters_fragment, action) } val concatAdapter = ConcatAdapter( filterAdapter, pagingAdapter.withLoadStateHeaderAndFooter( header = ResourceLoadStateAdapter(pagingAdapter), footer = ResourceLoadStateAdapter(pagingAdapter) ) ) binding.rvMedia.adapter = concatAdapter binding.rvMedia.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { pagingAdapter.loadStateFlow.collectLatest { loadState -> binding.layoutLoading.updateLoadState( binding.rvMedia, pagingAdapter.itemCount, loadState ) } } } viewModel.languages.observe(viewLifecycleOwner) { languages -> filterAdapter.isViewHolderVisible = languages.isNotEmpty() filterAdapter.languages = languages filterAdapter.selectedLanguage = viewModel.selectedLanguage filterAdapter.notifyItemChanged() } viewModel.isLoadingLanguages.observe(viewLifecycleOwner) { isLoading -> binding.layoutLoading.apply { root.isVisible = isVisible progressBar.isVisible = isLoading tvError.isVisible = false btnRetry.isVisible = false } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.dataSource.collectLatest { data -> pagingAdapter.submitData(data) } } } } override fun onNavigationItemReselected(item: MenuItem) { if (binding.rvMedia.canScrollVertically(-1)) { binding.rvMedia.smoothScrollToPosition(0) binding.appBarLayout.setExpanded(true) } else { findNavController().navigateUp() } } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/characters/CharactersViewModel.kt ================================================ package io.github.drumber.kitsune.ui.details.characters import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.presentation.model.media.production.Casting import io.github.drumber.kitsune.data.repository.AnimeRepository import io.github.drumber.kitsune.data.repository.CastingRepository import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class CharactersViewModel( private val castingRepository: CastingRepository, private val animeRepository: AnimeRepository ) : ViewModel() { private val filter = MutableLiveData() private var mediaId: String? = null private val _languages = MutableLiveData>() val languages: LiveData> get() = _languages var selectedLanguage: String? = null private set private val _isLoadingLanguages = MutableLiveData(false) val isLoadingLanguages: LiveData get() = _isLoadingLanguages fun setMediaId(id: String, isAnime: Boolean) { if (id == mediaId) return mediaId = id if (isAnime) { // fetch all languages of the anime viewModelScope.launch(Dispatchers.IO) { val langs = fetchLanguages(id) ?: emptyList() // select Japanese, English or the first language in the list selectedLanguage = langs.find { it == "Japanese" } ?: langs.find { it == "English" } ?: langs.firstOrNull() withContext(Dispatchers.Main) { _languages.value = langs updateFilter() } } } else { updateFilter() } } fun retry(id: String, isAnime: Boolean) { mediaId = null setMediaId(id, isAnime) } fun setLanguage(language: String) { if (languages.value?.contains(language) == true) { selectedLanguage = language updateFilter() } } private fun updateFilter() { val id = mediaId ?: return val filter = Filter() .filter("media_id", id) .filter("is_character", "true") .include("character", "person") .sort("-featured") selectedLanguage?.let { filter.filter("language", it) } this.filter.value = filter } private suspend fun fetchLanguages(id: String): List? { _isLoadingLanguages.postValue(true) return try { animeRepository.getLanguages(id) } catch (e: Exception) { logE("Failed to fetch languages for anime with id '$id'.", e) null } finally { _isLoadingLanguages.postValue(false) } } val dataSource: Flow> = filter.asFlow().flatMapLatest { filter -> castingRepository.castingPager(filter, Kitsu.DEFAULT_PAGE_SIZE) }.cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/episodes/EpisodesFragment.kt ================================================ package io.github.drumber.kitsune.ui.details.episodes import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.google.android.material.navigation.NavigationBarView import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.presentation.dto.toMedia import io.github.drumber.kitsune.data.presentation.dto.toMediaUnitDto import io.github.drumber.kitsune.data.presentation.model.media.unit.MediaUnit import io.github.drumber.kitsune.databinding.FragmentMediaListBinding import io.github.drumber.kitsune.ui.adapter.paging.MediaUnitPagingAdapter import io.github.drumber.kitsune.ui.adapter.paging.ResourceLoadStateAdapter import io.github.drumber.kitsune.ui.component.updateLoadState import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import io.github.drumber.kitsune.util.ui.showSnackbarOnFailure import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class EpisodesFragment : Fragment(R.layout.fragment_media_list), MediaUnitPagingAdapter.MediaUnitActionListener, NavigationBarView.OnItemReselectedListener { private val args: EpisodesFragmentArgs by navArgs() private var _binding: FragmentMediaListBinding? = null private val binding get() = _binding!! private val viewModel: EpisodesViewModel by viewModel() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentMediaListBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.setMedia(args.media.toMedia()) binding.collapsingToolbar.initWindowInsetsListener(consume = false) binding.toolbar.apply { initWindowInsetsListener(consume = false) title = getString( when (args.media.type) { MediaType.Anime -> R.string.title_episodes MediaType.Manga -> R.string.title_chapters } ) setNavigationOnClickListener { findNavController().navigateUp() } } binding.rvMedia.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.libraryUpdateResultFlow.collectLatest { it.showSnackbarOnFailure(binding.rvMedia) } } } val adapter = MediaUnitPagingAdapter( Glide.with(this), args.media.toMedia().posterImageUrl, viewModel.libraryEntryWrapper.value != null, this ) binding.rvMedia.adapter = adapter.withLoadStateHeaderAndFooter( header = ResourceLoadStateAdapter(adapter), footer = ResourceLoadStateAdapter(adapter) ) binding.rvMedia.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.VERTICAL, false) binding.layoutLoading.btnRetry.setOnClickListener { adapter.retry() } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { adapter.loadStateFlow.collectLatest { loadState -> binding.layoutLoading.updateLoadState( binding.rvMedia, adapter.itemCount, loadState ) } } } viewModel.libraryEntryWrapper.observe(viewLifecycleOwner) { adapter.setIsWatchedCheckboxEnabled(it != null) it?.progress?.let { progress -> adapter.updateLibraryWatchCount(progress) } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.dataSource.collectLatest { data -> adapter.submitData(data) } } } } private fun showDetailsBottomSheet(mediaUnit: MediaUnit) { val sheetMediaUnit = MediaUnitDetailsBottomSheet() sheetMediaUnit.arguments = bundleOf( MediaUnitDetailsBottomSheet.BUNDLE_MEDIA_UNIT_ADAPTER to mediaUnit.toMediaUnitDto(), MediaUnitDetailsBottomSheet.BUNDLE_THUMBNAIL to args.media.toMedia().posterImageUrl ) sheetMediaUnit.show(parentFragmentManager, MediaUnitDetailsBottomSheet.TAG) } override fun onMediaUnitClicked(mediaUnit: MediaUnit) { showDetailsBottomSheet(mediaUnit) } override fun onWatchStateChanged(mediaUnit: MediaUnit, isWatched: Boolean) { viewModel.setMediaUnitWatched(mediaUnit, isWatched) } override fun onNavigationItemReselected(item: MenuItem) { if (binding.rvMedia.canScrollVertically(-1)) { binding.rvMedia.smoothScrollToPosition(0) binding.appBarLayout.setExpanded(true) } else { findNavController().navigateUp() } } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/episodes/EpisodesViewModel.kt ================================================ package io.github.drumber.kitsune.ui.details.episodes import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.presentation.model.media.unit.MediaUnit import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.data.repository.MediaUnitRepository import io.github.drumber.kitsune.data.repository.MediaUnitRepository.MediaUnitType import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryProgressUseCase import io.github.drumber.kitsune.domain.user.GetLocalUserIdUseCase import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch class EpisodesViewModel( private val mediaUnitRepository: MediaUnitRepository, private val libraryRepository: LibraryRepository, private val updateLibraryEntryProgress: UpdateLibraryEntryProgressUseCase, private val getLocalUserId: GetLocalUserIdUseCase ) : ViewModel() { private val acceptLibraryUpdateResult: (LibraryEntryUpdateResult) -> Unit val libraryUpdateResultFlow: Flow private val media = MutableLiveData() val libraryEntryWrapper = media.switchMap { media -> val dbEntry = libraryRepository.getLibraryEntryWithModificationFromMediaAsLiveData(media.id) val userId = getLocalUserId() if (dbEntry.value == null && userId != null) { viewModelScope.launch(Dispatchers.IO) { try { libraryRepository.fetchAndStoreLibraryEntryForMedia(userId, media) } catch (e: Exception) { logE("Failed to fetch library entry for media: ${media.id}", e) } } } return@switchMap dbEntry } init { val mutableLibraryUpdateResultFlow = MutableSharedFlow() libraryUpdateResultFlow = mutableLibraryUpdateResultFlow.asSharedFlow() acceptLibraryUpdateResult = { viewModelScope.launch { mutableLibraryUpdateResultFlow.emit(it) } } } fun setMedia(media: Media) { if (media != this.media.value) { this.media.value = media } } fun setMediaUnitWatched(mediaUnit: MediaUnit, isWatched: Boolean) { val libraryEntry = libraryEntryWrapper.value?.libraryEntry ?: return val number = mediaUnit.number ?: 0 val progress = if (isWatched) { number } else { number.minus(1).coerceAtLeast(0) } viewModelScope.launch(Dispatchers.IO) { val updateResult = updateLibraryEntryProgress(libraryEntry, progress) acceptLibraryUpdateResult(updateResult) } } val dataSource: Flow> = media.asFlow().flatMapLatest { media -> val filter = Filter() .sort("number") val type = when (media) { is Anime -> { filter.filter("media_id", media.id) MediaUnitType.EPISODE } is Manga -> { filter.filter("manga_id", media.id) MediaUnitType.CHAPTER } } mediaUnitRepository.mediaUnitPager(type, filter, Kitsu.DEFAULT_PAGE_SIZE) }.cachedIn(viewModelScope) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/details/episodes/MediaUnitDetailsBottomSheet.kt ================================================ package io.github.drumber.kitsune.ui.details.episodes import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import com.bumptech.glide.Glide import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.dto.MediaUnitDto import io.github.drumber.kitsune.data.presentation.dto.toMediaUnit import io.github.drumber.kitsune.databinding.SheetMediaUnitDetailsBinding import io.github.drumber.kitsune.util.extensions.openPhotoViewActivity class MediaUnitDetailsBottomSheet : BottomSheetDialogFragment() { private var _binding: SheetMediaUnitDetailsBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = SheetMediaUnitDetailsBinding.inflate(inflater, container, false) val mediaUnitDto: MediaUnitDto? = arguments?.getParcelable(BUNDLE_MEDIA_UNIT_ADAPTER) val mediaUnit = mediaUnitDto?.toMediaUnit() binding.mediaUnit = mediaUnit val thumbnailUrl = mediaUnit?.thumbnail?.smallOrHigher() ?: arguments?.getString(BUNDLE_THUMBNAIL) Glide.with(this) .load(thumbnailUrl) .centerCrop() .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) binding.ivThumbnail.setOnClickListener { mediaUnit?.thumbnail?.originalOrDown()?.let { imageUrl -> val title = mediaUnit.title(requireContext()) openPhotoViewActivity(imageUrl, title, thumbnailUrl) } } return binding.root } override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val TAG = "media_unit_details_bottom_sheet" const val BUNDLE_MEDIA_UNIT_ADAPTER = "media_unit_adapter_bundle_key" const val BUNDLE_THUMBNAIL = "thumbnail_bundle_key" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/library/LibraryFragment.kt ================================================ package io.github.drumber.kitsune.ui.library import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.activity.addCallback import androidx.annotation.OptIn import androidx.appcompat.widget.SearchView import androidx.core.view.doOnPreDraw import androidx.core.view.isEmpty import androidx.core.view.isNotEmpty import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.paging.CombinedLoadStates import androidx.paging.LoadState import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SimpleItemAnimator import com.algolia.instantsearch.core.searcher.Debouncer import com.bumptech.glide.Glide import com.google.android.material.appbar.AppBarLayout import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils import com.google.android.material.badge.ExperimentalBadgeUtils import com.google.android.material.chip.Chip import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationBarView import com.google.android.material.shape.MaterialShapeDrawable import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.library.LibraryEntryKind import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryUiModel import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.databinding.FragmentLibraryBinding import io.github.drumber.kitsune.ui.adapter.paging.LibraryEntriesAdapter import io.github.drumber.kitsune.ui.adapter.paging.ResourceLoadStateAdapter import io.github.drumber.kitsune.ui.authentication.AuthenticationActivity import io.github.drumber.kitsune.ui.base.BaseFragment import io.github.drumber.kitsune.ui.component.ResponsiveGridLayoutManager import io.github.drumber.kitsune.ui.component.updateLoadState import io.github.drumber.kitsune.ui.library.LibraryChangeResult.LibrarySynchronizationResult import io.github.drumber.kitsune.ui.library.LibraryChangeResult.LibraryUpdateResult import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.extensions.setAppTheme import io.github.drumber.kitsune.util.extensions.toPx import io.github.drumber.kitsune.util.rating.RatingSystemUtil import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.showSnackbarOnAnyFailure import io.github.drumber.kitsune.util.ui.showSnackbarOnFailure import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class LibraryFragment : BaseFragment(R.layout.fragment_library, true), LibraryEntriesAdapter.LibraryEntryActionListener, NavigationBarView.OnItemReselectedListener { private var _binding: FragmentLibraryBinding? = null private val binding get() = _binding!! private val viewModel: LibraryViewModel by viewModel() private var offlineLibraryModificationsAmount = 0 private lateinit var offlineLibraryUpdateBadge: BadgeDrawable private var searchViewBackPressedCallback: OnBackPressedCallback? = null private val searchDebouncer by lazy { Debouncer(300L) } private val autoSyncDebouncer by lazy { Debouncer(5000L) } companion object { const val RESULT_KEY_RATING = "library_rating_result_key" const val RESULT_KEY_REMOVE_RATING = "library_remove_rating_result_key" const val RESULT_KEY_EDIT_ENTRY_UPDATED = "library_edit_entry_updated" } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) offlineLibraryUpdateBadge = BadgeDrawable.create(requireContext()) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentLibraryBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() if (!viewModel.hasUser() || findNavController().currentBackStackEntry?.arguments == null) { view.doOnPreDraw { startPostponedEnterTransition() } } else { // safeguard: ensure startPostponedEnterTransition() got called within 200ms view.postDelayed({ startPostponedEnterTransition() }, 200) } binding.apply { appBarLayout.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context) toolbar.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) scrollViewFilter.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) swipeRefreshLayout.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) layoutNotLoggedIn.initPaddingWindowInsetsListener( left = true, top = true, right = true, bottom = true, consume = false ) btnLogin.setOnClickListener { val intent = Intent(requireActivity(), AuthenticationActivity::class.java) startActivity(intent) } } val initialToolbarScrollFlags = (binding.toolbar.layoutParams as AppBarLayout.LayoutParams).scrollFlags viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.localUser.collectLatest { user -> val isLoggedIn = user != null binding.apply { initToolbarMenu(isVisible = isLoggedIn) rvLibraryEntries.isVisible = isLoggedIn nsvNotLoggedIn.isVisible = !isLoggedIn scrollViewFilter.isVisible = isLoggedIn // disable toolbar scrolling if library is not shown (not logged in) (toolbar.layoutParams as AppBarLayout.LayoutParams).scrollFlags = if (isLoggedIn) { initialToolbarScrollFlags } else { AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } } } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.libraryChangeResultFlow.collectLatest { when (it) { is LibraryUpdateResult -> it.result.showSnackbarOnFailure(binding.rvLibraryEntries) is LibrarySynchronizationResult -> it.results.showSnackbarOnAnyFailure( binding.rvLibraryEntries ) } } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state .map { it.isLibraryUpdateOperationInProgress } .distinctUntilChanged() .collectLatest { binding.progressIndicator.visibility = if (it) View.VISIBLE else View.INVISIBLE } } } setFragmentResultListener(RESULT_KEY_RATING) { _, bundle -> val rating = bundle.getInt(RatingBottomSheet.BUNDLE_RATING, -1) if (rating != -1) { viewModel.updateRating(rating) } } setFragmentResultListener(RESULT_KEY_REMOVE_RATING) { _, _ -> viewModel.updateRating(null) } setFragmentResultListener(RESULT_KEY_EDIT_ENTRY_UPDATED) { _, _ -> viewModel.triggerAdapterUpdate() } initToolbarMenu(isVisible = viewModel.hasUser()) initFilterChips() initRecyclerView() } private fun initFilterChips() { viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state .map { it.filter.kind } .distinctUntilChanged() .collectLatest { kind -> binding.chipMediaKind.setText( when (kind) { LibraryEntryKind.Anime -> R.string.anime LibraryEntryKind.Manga -> R.string.manga else -> R.string.library_kind_all } ) binding.chipCurrent.setText( if (kind == LibraryEntryKind.Manga) R.string.library_status_reading else R.string.library_status_watching ) binding.chipPlanned.setText( if (kind == LibraryEntryKind.Manga) R.string.library_status_planned_manga else R.string.library_status_planned ) } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.state .map { it.filter.libraryStatus } .distinctUntilChanged() .collectLatest { status -> binding.apply { chipCurrent.isChecked = status.contains(LibraryStatus.Current) chipPlanned.isChecked = status.contains(LibraryStatus.Planned) chipCompleted.isChecked = status.contains(LibraryStatus.Completed) chipOnHold.isChecked = status.contains(LibraryStatus.OnHold) chipDropped.isChecked = status.contains(LibraryStatus.Dropped) } } } } binding.apply { chipMediaKind.setOnClickListener { showMediaSelectorDialog() } chipCurrent.initStatusClickListener(LibraryStatus.Current) chipPlanned.initStatusClickListener(LibraryStatus.Planned) chipCompleted.initStatusClickListener(LibraryStatus.Completed) chipOnHold.initStatusClickListener(LibraryStatus.OnHold) chipDropped.initStatusClickListener(LibraryStatus.Dropped) } } private fun Chip.initStatusClickListener(status: LibraryStatus) { setOnClickListener { val statusList = viewModel.state.value.filter.libraryStatus.toMutableList() if (statusList.contains(status)) { statusList.remove(status) } else { statusList.add(status) } viewModel.setLibraryEntryStatus(statusList) } } private fun showMediaSelectorDialog() { val items = listOf(R.string.library_kind_all, R.string.anime, R.string.manga) .map { getString(it) }.toTypedArray() val prevSelected = viewModel.state.value.filter.kind.ordinal MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.title_media_type) .setSingleChoiceItems(items, prevSelected) { dialog, which -> if (which != prevSelected) { val kind = LibraryEntryKind.entries[which] viewModel.setLibraryEntryKind(kind) } dialog.dismiss() } .show() } private fun initRecyclerView() { val glide = Glide.with(this) val adapter = LibraryEntriesAdapter(glide, this) var lastLoadState: CombinedLoadStates? = null viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { adapter.loadStateFlow.collectLatest { state -> lastLoadState = state if (view?.parent == null) return@collectLatest if (!state.source.isIdle) { // start postponed transition immediate if loading from network view?.doOnPreDraw { startPostponedEnterTransition() } } else if (state.isIdle) { // else wait for adapter to idle view?.doOnPreDraw { startPostponedEnterTransition() } } val isSearching = viewModel.state.value.filter.searchQuery.isNotBlank() val isNotLoading = when { adapter.itemCount < 1 -> state.refresh is LoadState.NotLoading isSearching -> state.source.refresh is LoadState.NotLoading else -> state.mediator?.refresh !is LoadState.Loading || state.source.refresh is LoadState.NotLoading } binding.apply { layoutLoading.updateLoadState( rvLibraryEntries, adapter.itemCount, state, useRemoteMediator = true, checkIsNotLoading = { isNotLoading } ) swipeRefreshLayout.isRefreshing = swipeRefreshLayout.isRefreshing && state.source.refresh is LoadState.Loading } // Check if a library entry was updated and try to scroll to the item position val scrollToEntryId = viewModel.scrollToUpdatedEntryId if (scrollToEntryId != null && state.isIdle) { val indexOfUpdatedEntry = adapter.snapshot() .indexOfFirst { (it as? LibraryEntryUiModel.EntryModel)?.entry?.id == scrollToEntryId } if (indexOfUpdatedEntry != -1) { binding.rvLibraryEntries.scrollToPosition(indexOfUpdatedEntry) viewModel.hasScrolledToUpdatedEntry() } else { // item was not found in paging snapshot, reset scrollToUpdatedEntryId viewModel.hasScrolledToUpdatedEntry() } } } } } binding.rvLibraryEntries.apply { initPaddingWindowInsetsListener(bottom = true, consume = false) this.adapter = adapter.withLoadStateHeaderAndFooter( header = ResourceLoadStateAdapter(adapter), footer = ResourceLoadStateAdapter(adapter) ) layoutManager = ResponsiveGridLayoutManager(context, 350.toPx(), 1).apply { spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { return if (adapter.getItemViewType(position) == R.layout.item_library_status_separator) { spanCount } else { 1 } } } } // disable change animation to prevent "blinking" (itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (dy != 0) { val currentFilter = viewModel.state.value.filter viewModel.acceptAction(UiAction.Scroll(currentFilter)) } } }) val notLoading = adapter.loadStateFlow .map { it.isIdle } .distinctUntilChanged() val hasNotScrolledForCurrentFilter = viewModel.state .map { it.hasNotScrolledForCurrentFilter } .distinctUntilChanged() val shouldScrollToTop = combine( notLoading, hasNotScrolledForCurrentFilter, Boolean::and ).distinctUntilChanged() viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { shouldScrollToTop.collectLatest { shouldScroll -> if (shouldScroll) { scrollToPosition(0) } } } } } binding.swipeRefreshLayout.apply { setAppTheme() setOnRefreshListener { if (offlineLibraryModificationsAmount > 0) { viewModel.synchronizeOfflineLibraryUpdates() } adapter.refresh() } } viewModel.doRefreshListener = { adapter.refresh() } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.pagingDataFlow.collect { adapter.submitData(it) } } } viewModel.notSynchronizedLibraryEntryModifications.observe(viewLifecycleOwner) { if (!viewModel.hasUser()) return@observe viewModel.invalidatePagingSource() offlineLibraryModificationsAmount = it.size updateToolbarMenu(binding.toolbar.menu) autoSyncDebouncer.debounce(viewLifecycleOwner.lifecycleScope) { // synchronize library if there are offline library updates and network is not metered val connectivityManager = requireActivity().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager if (it.isNotEmpty() && !connectivityManager.isActiveNetworkMetered) { viewModel.synchronizeOfflineLibraryUpdates() } } } } override fun onItemClicked(view: View, item: LibraryEntryWithModification) { val media = item.libraryEntry.media if (media != null) { val detailsTransitionName = getString(R.string.details_poster_transition_name) val extras = FragmentNavigatorExtras(view.findViewById(R.id.iv_thumbnail) to detailsTransitionName) val action = LibraryFragmentDirections.actionLibraryFragmentToDetailsFragment(media.toMediaDto()) findNavController().navigateSafe(R.id.library_fragment, action, extras) } } override fun onItemLongClicked(item: LibraryEntryWithModification) { val action = LibraryFragmentDirections.actionLibraryFragmentToLibraryEditEntryFragment( item.libraryEntry.id, RESULT_KEY_EDIT_ENTRY_UPDATED ) findNavController().navigateSafe(R.id.library_fragment, action) } override fun onEpisodeWatchedClicked(item: LibraryEntryWithModification) { viewModel.markEpisodeWatched(item) } override fun onEpisodeUnwatchedClicked(item: LibraryEntryWithModification) { viewModel.markEpisodeUnwatched(item) } override fun onRatingClicked(item: LibraryEntryWithModification) { viewModel.lastRatedLibraryEntry = item.libraryEntry val action = LibraryFragmentDirections.actionLibraryFragmentToRatingBottomSheet( title = item.media?.title ?: "", ratingTwenty = item.ratingTwenty ?: -1, ratingResultKey = RESULT_KEY_RATING, removeResultKey = RESULT_KEY_REMOVE_RATING, ratingSystem = RatingSystemUtil.getRatingSystem() ) findNavController().navigateSafe(R.id.library_fragment, action) } private fun initToolbarMenu(isVisible: Boolean) { val toolbar = binding.toolbar if (isVisible && toolbar.menu.isNotEmpty()) return toolbar.menu.clear() searchViewBackPressedCallback?.remove() if (!isVisible) return toolbar.inflateMenu(R.menu.library_menu) toolbar.setOnMenuItemClickListener { item -> return@setOnMenuItemClickListener if (item.itemId == R.id.menu_synchronize) { viewModel.synchronizeOfflineLibraryUpdates() true } else { false } } initSearchView(toolbar.menu.findItem(R.id.menu_search)) updateToolbarMenu(toolbar.menu) } @OptIn(ExperimentalBadgeUtils::class) private fun updateToolbarMenu(menu: Menu) { if (menu.isEmpty()) return BadgeUtils.detachBadgeDrawable( offlineLibraryUpdateBadge, binding.toolbar, R.id.menu_synchronize ) val searchMenuItem = menu.findItem(R.id.menu_search) updateSynchronizationMenuItem(!searchMenuItem.isActionViewExpanded) } @OptIn(ExperimentalBadgeUtils::class) private fun updateSynchronizationMenuItem(isSearchViewCollapsed: Boolean) { val synchronizeMenuItem = binding.toolbar.menu.findItem(R.id.menu_synchronize) ?: return val showMenuItem = offlineLibraryModificationsAmount > 0 && isSearchViewCollapsed synchronizeMenuItem.isVisible = showMenuItem if (showMenuItem) { offlineLibraryUpdateBadge.apply { isVisible = true number = offlineLibraryModificationsAmount } BadgeUtils.attachBadgeDrawable( offlineLibraryUpdateBadge, binding.toolbar, R.id.menu_synchronize ) } } private fun initSearchView(menuItem: MenuItem) { val searchView = menuItem.actionView as SearchView searchView.queryHint = getString(R.string.hint_search) val searchQueryText = viewModel.state.value.filter.searchQuery if (searchQueryText.isNotBlank()) { // restore previous search view state menuItem.expandActionView() searchView.post { if (!isAdded) return@post searchView.setQuery(searchQueryText, false) } } searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { searchDebouncer.debounce(lifecycleScope) { viewModel.searchLibrary(query ?: "") } return false } override fun onQueryTextChange(newText: String?): Boolean { searchDebouncer.debounce(lifecycleScope) { viewModel.searchLibrary(newText ?: "") } return false } }) searchViewBackPressedCallback = requireActivity().onBackPressedDispatcher.addCallback( viewLifecycleOwner, menuItem.isActionViewExpanded ) { menuItem.collapseActionView() isEnabled = false } menuItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(item: MenuItem): Boolean { if (isAdded) { searchViewBackPressedCallback?.isEnabled = true updateSynchronizationMenuItem(false) } return true } override fun onMenuItemActionCollapse(item: MenuItem): Boolean { viewModel.searchLibrary("") searchViewBackPressedCallback?.isEnabled = false if (isAdded) { updateSynchronizationMenuItem(true) if (searchView.query.isNotBlank()) { binding.rvLibraryEntries.apply { post { if (!isAdded) return@post scrollToPosition(0) } } } } return true } }) } override fun onNavigationItemReselected(item: MenuItem) { binding.rvLibraryEntries.smoothScrollToPosition(0) binding.appBarLayout.setExpanded(true) } override fun onDestroyView() { viewModel.doRefreshListener = null searchViewBackPressedCallback?.remove() searchViewBackPressedCallback = null super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/library/LibraryViewModel.kt ================================================ package io.github.drumber.kitsune.ui.library import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.TerminalSeparatorType import androidx.paging.cachedIn import androidx.paging.insertSeparators import androidx.paging.map import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.library.LibraryEntryKind import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryFilter import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryUiModel import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryModificationState.NOT_SYNCHRONIZED import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.domain.library.GetLibraryEntriesWithModificationsPagerUseCase import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult import io.github.drumber.kitsune.domain.library.SearchLibraryEntriesWithLocalModificationsPagerUseCase import io.github.drumber.kitsune.domain.library.SynchronizeLocalLibraryModificationsUseCase import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryProgressUseCase import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryRatingUseCase import io.github.drumber.kitsune.domain.user.GetLocalUserIdUseCase import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.library.InternalAction.LibraryUpdateOperationEnd import io.github.drumber.kitsune.ui.library.InternalAction.LibraryUpdateOperationStart import io.github.drumber.kitsune.ui.library.LibraryChangeResult.LibrarySynchronizationResult import io.github.drumber.kitsune.ui.library.LibraryChangeResult.LibraryUpdateResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger class LibraryViewModel( private val userRepository: UserRepository, private val getLocalUserId: GetLocalUserIdUseCase, private val libraryRepository: LibraryRepository, private val getLibraryEntriesWithModifications: GetLibraryEntriesWithModificationsPagerUseCase, private val searchLibraryEntriesWithModification: SearchLibraryEntriesWithLocalModificationsPagerUseCase, private val updateLibraryEntryProgress: UpdateLibraryEntryProgressUseCase, private val updateLibraryEntryRating: UpdateLibraryEntryRatingUseCase, private val synchronizeLocalLibraryModifications: SynchronizeLocalLibraryModificationsUseCase ) : ViewModel() { val state: StateFlow val pagingDataFlow: Flow> val acceptAction: (UiAction) -> Unit private val acceptInternalAction: (InternalAction) -> Unit /** * The ID of the last updated entry the recycler view should scroll to. */ var scrollToUpdatedEntryId: String? = null private set var doRefreshListener: (() -> Unit)? = null val libraryChangeResultFlow: Flow val notSynchronizedLibraryEntryModifications = libraryRepository.getLibraryEntryModificationsByStateAsLiveData(NOT_SYNCHRONIZED) private val libraryProgressUpdateJobs = ConcurrentHashMap() val localUser = userRepository.localUser init { val initialFilter = FilterState( KitsunePref.libraryEntryKind, KitsunePref.libraryEntryStatus, ) val actionStateFlow = MutableSharedFlow() val searches = actionStateFlow .filterIsInstance() .distinctUntilChanged() .onStart { emit(UiAction.Filter(initialFilter)) } val queriesScrolled = actionStateFlow .filterIsInstance() .distinctUntilChanged() .onStart { emit(null) } val libraryUpdateOperationsCounter = AtomicInteger(0) val internalActionFlow = MutableSharedFlow() val internalActionState = internalActionFlow .onEach { action -> when (action) { is LibraryUpdateOperationStart -> libraryUpdateOperationsCounter.incrementAndGet() is LibraryUpdateOperationEnd -> libraryUpdateOperationsCounter.decrementAndGet() else -> {} } } .map { InternalState( libraryOperationsCount = libraryUpdateOperationsCounter.get() ) } .distinctUntilChanged() .onStart { emit(InternalState(libraryUpdateOperationsCounter.get())) } libraryChangeResultFlow = internalActionFlow .mapNotNull { when (it) { is InternalAction.LibraryUpdateResult -> LibraryUpdateResult(it.result) is InternalAction.LibrarySynchronizationResult -> LibrarySynchronizationResult( it.results ) else -> null } } pagingDataFlow = searches .mapNotNull { createLibraryEntryFilter(it.filter) } .flatMapLatest { filter -> getPagingLibraryEntriesFlow(filter) .insertSeparators(filter) } .cachedIn(viewModelScope) state = combine( searches, queriesScrolled, internalActionState ) { search, scroll, internalState -> UiState( filter = search.filter, hasNotScrolledForCurrentFilter = search.filter != scroll?.currentFilter, isLibraryUpdateOperationInProgress = internalState.libraryOperationsCount != 0 ) }.stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = UiState(initialFilter) ) acceptAction = { inputAction -> val action = if (inputAction is UiAction.Filter) { inputAction.copy(filter = inputAction.filter.copy(createTime = System.currentTimeMillis())) } else { inputAction } viewModelScope.launch { actionStateFlow.emit(action) } } acceptInternalAction = { action -> viewModelScope.launch { internalActionFlow.emit(action) } } } fun hasUser() = userRepository.hasLocalUser() private fun getPagingLibraryEntriesFlow( filter: LibraryEntryFilter ): Flow> { return if (filter.isFilteredBySearchQuery()) { // if filter contains a search query, then search directly using the paging source searchLibraryEntriesWithModification( Kitsu.DEFAULT_PAGE_SIZE_LIBRARY, filter.buildFilter(), viewModelScope ) } else { // otherwise use the default paging source getLibraryEntriesWithModifications( Kitsu.DEFAULT_PAGE_SIZE_LIBRARY, filter, viewModelScope ) } } private fun Flow>.insertSeparators( filter: LibraryEntryFilter ): Flow> { return map { pagingData -> pagingData.map { LibraryEntryUiModel.EntryModel(it) } }.map { it.insertSeparators(TerminalSeparatorType.SOURCE_COMPLETE) { before, after -> when { // do not insert separators if library is currently searched filter.isFilteredBySearchQuery() -> null after?.entry?.libraryEntry?.status == null -> null before == null || before.entry.libraryEntry.status != after.entry.libraryEntry.status -> LibraryEntryUiModel.StatusSeparatorModel( status = after.entry.libraryEntry.status, isMangaSelected = filter.kind == LibraryEntryKind.Manga ) else -> null } } } } private fun createLibraryEntryFilter(filter: FilterState): LibraryEntryFilter? { return getLocalUserId()?.let { userId -> val requestFilter = Filter() .filter("user_id", userId) .sort("status", "-progressed_at") .include("anime", "manga") // if the search query is not blank, add it to the filter and we will search for the given query if (filter.searchQuery.isNotBlank()) { requestFilter.filter("title", filter.searchQuery) } LibraryEntryFilter( kind = filter.kind, libraryStatus = filter.libraryStatus, initialFilter = requestFilter ) } } fun searchLibrary(searchQueryText: String) { val currentFilter = state.value.filter if (currentFilter.searchQuery.trim() != searchQueryText.trim()) { acceptAction(UiAction.Filter(currentFilter.copy(searchQuery = searchQueryText))) } } fun invalidatePagingSource() { libraryRepository.invalidatePagingSources() } fun setLibraryEntryKind(kind: LibraryEntryKind) { KitsunePref.libraryEntryKind = kind val currentFilter = state.value.filter acceptAction(UiAction.Filter(currentFilter.copy(kind = kind))) } fun setLibraryEntryStatus(status: List) { // clear status filter if all filters are selected val statusFilter = if (status.size == 5) emptyList() else status KitsunePref.libraryEntryStatus = statusFilter val currentFilter = state.value.filter acceptAction(UiAction.Filter(currentFilter.copy(libraryStatus = statusFilter))) } fun synchronizeOfflineLibraryUpdates() { acceptInternalAction(LibraryUpdateOperationStart) viewModelScope.launch(Dispatchers.IO) { val librarySyncResults = synchronizeLocalLibraryModifications() acceptInternalAction(InternalAction.LibrarySynchronizationResult(librarySyncResults.values.toList())) }.invokeOnCompletion { acceptInternalAction(LibraryUpdateOperationEnd) } } fun markEpisodeWatched(libraryEntryWrapper: LibraryEntryWithModification) { val currentProgress = libraryEntryWrapper.progress ?: 0 val newProgress = currentProgress + 1 updateLibraryProgress(libraryEntryWrapper.libraryEntry, newProgress) } fun markEpisodeUnwatched(libraryEntryWrapper: LibraryEntryWithModification) { val currentProgress = libraryEntryWrapper.progress ?: 0 if (currentProgress == 0) return val newProgress = currentProgress - 1 updateLibraryProgress(libraryEntryWrapper.libraryEntry, newProgress) } private fun updateLibraryProgress(libraryEntry: LibraryEntry, newProgress: Int) { val ongoingJob = libraryProgressUpdateJobs[libraryEntry.id] val job = viewModelScope.launch(Dispatchers.IO) { ongoingJob?.cancelAndJoin() // wait until ongoing update call is cancelled performLibraryEntryUpdate { updateLibraryEntryProgress(libraryEntry, newProgress) } } libraryProgressUpdateJobs[libraryEntry.id] = job job.invokeOnCompletion { if (libraryProgressUpdateJobs[libraryEntry.id] == job) { libraryProgressUpdateJobs.remove(libraryEntry.id) } } } /** Set to the library entry which rating should be updated. */ var lastRatedLibraryEntry: LibraryEntry? = null fun updateRating(rating: Int?) { val libraryEntry = lastRatedLibraryEntry ?: return viewModelScope.launch(Dispatchers.IO) { performLibraryEntryUpdate { updateLibraryEntryRating(libraryEntry, rating) } } } private suspend fun performLibraryEntryUpdate(block: suspend () -> LibraryEntryUpdateResult) { acceptInternalAction(LibraryUpdateOperationStart) val updateResult = try { block() } finally { acceptInternalAction(LibraryUpdateOperationEnd) } acceptInternalAction(InternalAction.LibraryUpdateResult(updateResult)) if (updateResult is LibraryEntryUpdateResult.Success) { scrollToUpdatedEntry(updateResult.updatedLibraryEntry.id) } if (updateResult is LibraryEntryUpdateResult.Success && state.value.filter.searchQuery.isNotBlank()) { // trigger new search to show the updated data withContext(Dispatchers.Main) { triggerAdapterUpdate() } } } private fun scrollToUpdatedEntry(libraryEntryId: String?) { scrollToUpdatedEntryId = libraryEntryId } /** * Signals that the recycler view was scrolled to the updated entry. */ fun hasScrolledToUpdatedEntry() { scrollToUpdatedEntryId = null } fun triggerAdapterUpdate() { doRefreshListener?.invoke() } } sealed class UiAction { data class Filter(val filter: FilterState) : UiAction() data class Scroll(val currentFilter: FilterState) : UiAction() } data class UiState( val filter: FilterState, val hasNotScrolledForCurrentFilter: Boolean = true, val isLibraryUpdateOperationInProgress: Boolean = false ) data class FilterState( val kind: LibraryEntryKind = LibraryEntryKind.All, val libraryStatus: List = emptyList(), val searchQuery: String = "", val createTime: Long = System.currentTimeMillis(), ) sealed class LibraryChangeResult { data class LibraryUpdateResult(val result: LibraryEntryUpdateResult) : LibraryChangeResult() data class LibrarySynchronizationResult(val results: List) : LibraryChangeResult() } private sealed class InternalAction { data object LibraryUpdateOperationStart : InternalAction() data object LibraryUpdateOperationEnd : InternalAction() data class LibraryUpdateResult(val result: LibraryEntryUpdateResult) : InternalAction() data class LibrarySynchronizationResult(val results: List) : InternalAction() } private data class InternalState( val libraryOperationsCount: Int ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/library/RatingBottomSheet.kt ================================================ package io.github.drumber.kitsune.ui.library import android.annotation.SuppressLint import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResult import androidx.navigation.fragment.navArgs import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.SheetLibraryRatingBinding import io.github.drumber.kitsune.util.rating.RatingSystemUtil import io.github.drumber.kitsune.util.rating.RatingSystemUtil.convertFrom import io.github.drumber.kitsune.util.rating.RatingSystemUtil.convertToRatingTwenty import io.github.drumber.kitsune.util.rating.RatingSystemUtil.fromRatingTwentyTo import io.github.drumber.kitsune.util.rating.RatingSystemUtil.stepSize import io.github.drumber.kitsune.util.rating.RatingSystemUtil.toRatingTwentyFrom class RatingBottomSheet : BottomSheetDialogFragment() { private var _binding: SheetLibraryRatingBinding? = null private val binding get() = _binding!! private val args: RatingBottomSheetArgs by navArgs() private val ratingSystem get() = args.ratingSystem override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = SheetLibraryRatingBinding.inflate(inflater, container, false) val ratingTwenty = args.ratingTwenty.takeIf { it != -1 } val hasNoRating = ratingTwenty == null || ratingTwenty == 0 binding.apply { title = args.title ratingBar.apply { setOnRatingChangeListener { ratingBar, rating -> val isInRange = ratingSystem.convertToRatingTwenty(rating) in 1..20 if (!isInRange) { val coercedRatingTwenty = ratingSystem.convertToRatingTwenty(rating) .coerceIn(1..20) ratingBar.post { if (!isAdded) return@post ratingBar.rating = ratingSystem.convertFrom(coercedRatingTwenty) } } btnRate.isEnabled = isInRange updateRatingTextView() } numStars = ratingSystem.convertFrom(20).toInt() stepSize = ratingSystem.stepSize() ratingTwenty?.fromRatingTwentyTo(ratingSystem)?.let { rating = it } } btnCancel.setOnClickListener { dismiss() } btnRate.setOnClickListener { onRateClicked() } btnRate.isEnabled = !hasNoRating btnRate.setText(if (hasNoRating) R.string.action_rate else R.string.action_update_rating) btnRemoveRating.setOnClickListener { onRemoveRatingClicked() } btnRemoveRating.isVisible = !hasNoRating } updateRatingTextView() return binding.root } @SuppressLint("SetTextI18n") private fun updateRatingTextView() { val rating = binding.ratingBar.rating binding.tvRating.text = if (rating == 0.0f) { getString(R.string.library_not_rated) } else { "$rating / ${RatingSystemUtil.formatRating(20, ratingSystem)}" } } private fun onRateClicked() { val ratingTwenty = binding.ratingBar.rating.toRatingTwentyFrom(ratingSystem) setFragmentResult(args.ratingResultKey, bundleOf(BUNDLE_RATING to ratingTwenty)) dismiss() } private fun onRemoveRatingClicked() { setFragmentResult(args.removeResultKey, bundleOf(BUNDLE_RATING to null)) dismiss() } override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val BUNDLE_RATING = "rating_bundle_key" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/library/editentry/LibraryEditEntryFragment.kt ================================================ package io.github.drumber.kitsune.ui.library.editentry import android.content.res.ColorStateList import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView import androidx.appcompat.widget.TooltipCompat import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.text.htmlEncode import androidx.core.text.parseAsHtml import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.setFragmentResult import androidx.fragment.app.setFragmentResultListener import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.bumptech.glide.Glide import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import io.github.drumber.kitsune.R import io.github.drumber.kitsune.addTransform import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.presentation.model.library.getStringResId import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.FragmentEditLibraryEntryBinding import io.github.drumber.kitsune.ui.base.BaseDialogFragment import io.github.drumber.kitsune.ui.component.CustomNumberSpinner import io.github.drumber.kitsune.ui.library.RatingBottomSheet import io.github.drumber.kitsune.ui.library.editentry.LibraryEditEntryViewModel.LoadState import io.github.drumber.kitsune.util.DATE_FORMAT_ISO import io.github.drumber.kitsune.util.extensions.getResourceId import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.formatDate import io.github.drumber.kitsune.util.formatUtcDate import io.github.drumber.kitsune.util.getLocalCalendar import io.github.drumber.kitsune.util.parseUtcDate import io.github.drumber.kitsune.util.rating.RatingSystemUtil import io.github.drumber.kitsune.util.rating.RatingSystemUtil.formatRatingTwenty import io.github.drumber.kitsune.util.stripTimeUtcMillis import io.github.drumber.kitsune.util.toDate import io.github.drumber.kitsune.util.ui.DateValidatorPointBetween import io.github.drumber.kitsune.util.ui.initImePaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initMarginWindowInsetsListener import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import org.koin.androidx.viewmodel.ext.android.viewModel class LibraryEditEntryFragment : BaseDialogFragment(R.layout.fragment_edit_library_entry) { private val args: LibraryEditEntryFragmentArgs by navArgs() private var _binding: FragmentEditLibraryEntryBinding? = null private val binding get() = _binding!! private val viewModel: LibraryEditEntryViewModel by viewModel() private var listenersInitialized = false companion object { const val RESULT_KEY_RATING = "library_edit_rating_result_key" const val RESULT_KEY_REMOVE_RATING = "library_edit_remove_rating_result_key" } private val libraryStatusMenuItems = listOf( LibraryStatus.Current, LibraryStatus.Planned, LibraryStatus.Completed, LibraryStatus.OnHold, LibraryStatus.Dropped ) override fun onStart() { requireDialog().window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) super.onStart() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentEditLibraryEntryBinding.inflate(inflater, container, false) viewModel.initLibraryEntry(args.libraryEntryId) binding.toolbar.initWindowInsetsListener(consume = false) binding.nestedScrollView.initMarginWindowInsetsListener( left = true, right = true, consume = false ) binding.layoutBottomBar.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) binding.root.initImePaddingWindowInsetsListener() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.apply { toolbar.setNavigationOnClickListener { dismiss() } TooltipCompat.setTooltipText( btnRemoveEntry, getString(R.string.library_action_remove) ) btnSaveChanges.setOnClickListener { viewModel.saveChanges() } btnRemoveEntry.setOnClickListener { val libraryEntry = viewModel.libraryEntry.value val dialogMsg = getString( R.string.dialog_remove_from_library_msg, libraryEntry?.media?.title?.htmlEncode() ?: getString(R.string.no_information) ).parseAsHtml() MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.dialog_remove_from_library_title) .setMessage(dialogMsg) .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } .setPositiveButton(R.string.action_remove) { dialog, _ -> dialog.dismiss() viewModel.removeLibraryEntry() } .show() } } viewModel.loadState.observe(viewLifecycleOwner) { state -> if (state == LoadState.CloseDialog) { args.entryUpdatedResultKey?.let { setFragmentResult(it, bundleOf()) } dismiss() } else { binding.layoutLoading.isVisible = state == LoadState.Loading if (state == LoadState.Error) { Snackbar.make( binding.root, R.string.error_library_update_failed, Snackbar.LENGTH_LONG ) .setAnchorView(binding.cardBottomBar) .show() } } } viewModel.libraryEntry.observe(viewLifecycleOwner) { libraryEntry -> val media = libraryEntry.media binding.tvTitle.text = media?.title binding.tvMediaInfo.text = media?.let { "${it.publishingYearText(binding.root.context)} • ${it.subtypeFormatted}" } Glide.with(this) .load(media?.posterImageUrl) .addTransform(RoundedCorners(8)) .placeholder(R.drawable.ic_insert_photo_48) .into(binding.ivThumbnail) } viewModel.libraryEntryWithModification.observe(viewLifecycleOwner) { libraryEntry -> val media = libraryEntry.media binding.apply { setLibraryStatusMenu(media, libraryEntry.status) media?.episodeOrChapterCount?.let { spinnerProgress.setMaxValue(it) } ?: spinnerProgress.setSuffixMode(CustomNumberSpinner.SuffixMode.Disabled) spinnerProgress.setValue(libraryEntry.progress ?: 0) layoutVolumes.isVisible = media is Manga spinnerVolumes.setMaxValue((media as? Manga)?.volumeCount ?: 0) spinnerVolumes.setValue(libraryEntry.volumesOwned ?: 0) val ratingTwenty = libraryEntry.ratingTwenty val hasRated = ratingTwenty != null && ratingTwenty != -1 fieldRating.editText?.apply { val ratingText = if (hasRated) { "${ratingTwenty!!.formatRatingTwenty()} / ${20.formatRatingTwenty()}" } else { getString(R.string.library_not_rated) } setText(ratingText) } fieldRating.setEndIconDrawable( if (hasRated) R.drawable.ic_star_24 else R.drawable.ic_star_outline_24 ) fieldRating.setEndIconTintList(ColorStateList.valueOf(getControlColor(hasRated))) tvReconsumeLabel.text = getString( if (media is Anime) R.string.library_edit_rewatch_count else R.string.library_edit_reread_count ) spinnerReconsume.setValue(libraryEntry.reconsumeCount ?: 0) spinnerReconsume.setActionTooltip( getString( if (media is Anime) R.string.library_edit_start_rewatch else R.string.library_edit_start_reread ) ) val privacyAdapter = ArrayAdapter( requireContext(), R.layout.item_dropdown, listOf( getString(R.string.library_edit_privacy_public), getString(R.string.library_edit_privacy_private) ) ) (menuPrivacy.editText as? AutoCompleteTextView)?.apply { setAdapter(privacyAdapter) val currValue = if (libraryEntry.isPrivate == true) getString(R.string.library_edit_privacy_private) else getString(R.string.library_edit_privacy_public) setText(currValue, false) } val startedText = libraryEntry.startedAt?.parseUtcDate()?.formatDate() ?: getString(R.string.library_edit_no_date_set) fieldStarted.editText?.setText(startedText) btnClearStarted.isVisible = !libraryEntry.startedAt.isNullOrEmpty() val finishedText = libraryEntry.finishedAt?.parseUtcDate()?.formatDate() ?: getString(R.string.library_edit_no_date_set) fieldFinished.editText?.setText(finishedText) btnClearFinished.isVisible = !libraryEntry.finishedAt.isNullOrEmpty() fieldNotes.editText?.apply { if (text.toString() != (libraryEntry.notes ?: "")) { setText(libraryEntry.notes) } } } if (!listenersInitialized) { initListeners() } } viewModel.hasChanges.observe(viewLifecycleOwner) { hasChanges -> binding.btnSaveChanges.isEnabled = hasChanges } setFragmentResultListener(RESULT_KEY_RATING) { _, bundle -> val rating = bundle.getInt(RatingBottomSheet.BUNDLE_RATING, -1) if (rating != -1) { viewModel.updateLibraryEntry { it.copy(ratingTwenty = rating) } } } setFragmentResultListener(RESULT_KEY_REMOVE_RATING) { _, _ -> val oldRating = viewModel.uneditedLibraryEntryWrapper?.ratingTwenty val rating = if (oldRating == null) null else -1 viewModel.updateLibraryEntry { it.copy(ratingTwenty = rating) } } } private fun initListeners() { listenersInitialized = true binding.apply { (menuLibraryStatus.editText as? AutoCompleteTextView)?.setOnItemClickListener { _, _, position, _ -> val status = libraryStatusMenuItems[position] viewModel.updateLibraryEntry { it.copy(status = status) } } spinnerProgress.setValueChangedListener { value -> val wrapper = viewModel.libraryEntryWithModification.value if (value == wrapper?.media?.episodeOrChapterCount) { viewModel.updateLibraryEntry { it.copy( progress = value, status = LibraryStatus.Completed, finishedAt = wrapper.finishedAt ?: getLocalCalendar().formatUtcDate() ) } } else { viewModel.updateLibraryEntry { it.copy(progress = value) } } } spinnerVolumes.setValueChangedListener { value -> viewModel.updateLibraryEntry { it.copy(volumesOwned = value) } } fieldRating.editText?.setOnClickListener { showRatingBottomSheet() } spinnerReconsume.setValueChangedListener { value -> viewModel.updateLibraryEntry { it.copy(reconsumeCount = value) } } // start reconsume action spinnerReconsume.setActionClickListener { val wrapper = viewModel.libraryEntryWithModification.value viewModel.updateLibraryEntry { it.copy( progress = 0, reconsumeCount = wrapper?.reconsumeCount?.plus(1) ?: 1, status = LibraryStatus.Current ) } } (menuPrivacy.editText as? AutoCompleteTextView)?.setOnItemClickListener { _, _, position, _ -> val isPrivate = position == 1 viewModel.updateLibraryEntry { it.copy(privateEntry = isPrivate) } } fieldStarted.editText?.setOnClickListener { val wrapper = viewModel.libraryEntryWithModification.value val selection = wrapper?.startedAt?.parseUtcDate()?.time ?.stripTimeUtcMillis() ?: MaterialDatePicker.todayInUtcMilliseconds() val validator = wrapper?.finishedAt?.parseUtcDate()?.time ?.stripTimeUtcMillis()?.let { DateValidatorPointBackward.before(it) } ?: DateValidatorPointBackward.now() openDatePicker( getString(R.string.library_edit_started), selection, validator ) { dateMillis -> val dateString = dateMillis.toDate().formatDate(DATE_FORMAT_ISO) viewModel.updateLibraryEntry { it.copy(startedAt = dateString) } } } btnClearStarted.setOnClickListener { viewModel.updateLibraryEntry { it.copy(startedAt = "") } } fieldFinished.editText?.setOnClickListener { val wrapper = viewModel.libraryEntryWithModification.value val selection = wrapper?.finishedAt?.parseUtcDate()?.time ?.stripTimeUtcMillis() ?: MaterialDatePicker.todayInUtcMilliseconds() val validator = wrapper?.startedAt?.parseUtcDate()?.time ?.stripTimeUtcMillis()?.let { DateValidatorPointBetween.nowAndFrom(it) } ?: DateValidatorPointBackward.now() openDatePicker( getString(R.string.library_edit_finished), selection, validator ) { dateMillis -> val dateString = dateMillis.toDate().formatDate(DATE_FORMAT_ISO) viewModel.updateLibraryEntry { it.copy(finishedAt = dateString) } } } btnClearFinished.setOnClickListener { viewModel.updateLibraryEntry { it.copy(finishedAt = "") } } fieldNotes.editText?.doAfterTextChanged { text -> val oldNotes = viewModel.uneditedLibraryEntryWrapper?.notes val note = if (oldNotes == null && text?.toString().isNullOrBlank()) { // if old note is null and we haven't edited the note, then set it to null null } else { text?.toString() } viewModel.updateLibraryEntry { it.copy(notes = note) } } } } private fun setLibraryStatusMenu(media: Media?, currValue: LibraryStatus?) { val isAnime = media is Anime val statusItems = libraryStatusMenuItems.map { getString(it.getStringResId(isAnime)) } val adapter = ArrayAdapter( requireContext(), R.layout.item_dropdown, statusItems ) (binding.menuLibraryStatus.editText as? AutoCompleteTextView)?.apply { setAdapter(adapter) currValue?.let { setText(getString(it.getStringResId(isAnime)), false) } } } private fun showRatingBottomSheet() { val libraryEntryWrapper = viewModel.libraryEntryWithModification.value ?: return val libraryEntry = viewModel.libraryEntryWithModification.value?.libraryEntry ?: return val media = libraryEntry.media ?: return val action = LibraryEditEntryFragmentDirections.actionLibraryEditEntryFragmentToRatingBottomSheet( title = media.title ?: "", ratingTwenty = libraryEntryWrapper.ratingTwenty ?: -1, ratingResultKey = RESULT_KEY_RATING, removeResultKey = RESULT_KEY_REMOVE_RATING, ratingSystem = RatingSystemUtil.getRatingSystem() ) findNavController().navigateSafe(R.id.libraryEditEntryFragment, action) } private fun openDatePicker( title: String, selection: Long, validator: CalendarConstraints.DateValidator, action: (Long) -> Unit ) { val constraints = CalendarConstraints.Builder() .setValidator(validator) .setEnd(MaterialDatePicker.todayInUtcMilliseconds()) .build() val datePicker = MaterialDatePicker.Builder.datePicker() .setTitleText(title) .setSelection(selection) .setCalendarConstraints(constraints) .build() datePicker.addOnPositiveButtonClickListener(action) datePicker.show(parentFragmentManager, "DATE_PICKER_$title") } private fun getControlColor(accent: Boolean): Int { return ContextCompat.getColor( requireContext(), requireActivity().theme.getResourceId( if (accent) R.attr.colorPrimary else R.attr.colorControlNormal ) ) } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/library/editentry/LibraryEditEntryViewModel.kt ================================================ package io.github.drumber.kitsune.ui.library.editentry import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateFailureReason.NotFound import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult.Failure import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult.Success import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryUseCase import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class LibraryEditEntryViewModel( private val libraryRepository: LibraryRepository, private val updateLibraryEntryUseCase: UpdateLibraryEntryUseCase ) : ViewModel() { var uneditedLibraryEntryWrapper: LibraryEntryWithModification? = null private set private val _libraryEntryWithModification = MutableLiveData() val libraryEntryWithModification get() = _libraryEntryWithModification as LiveData private val _libraryEntry = MutableLiveData() val libraryEntry get() = _libraryEntry as LiveData private val _loadState = MutableLiveData(LoadState.NotLoading) val loadState get() = _loadState as LiveData val hasChanges: LiveData = libraryEntryWithModification.map { val uneditedWrapper = uneditedLibraryEntryWrapper ?: return@map false val entry = uneditedWrapper.libraryEntry.copy() // apply old modifications val oldModifiedEntry = uneditedWrapper.modification ?.applyToLibraryEntry(entry) ?: entry // apply new modifications val newModifiedEntry = it.modification ?.applyToLibraryEntry(entry) ?: entry oldModifiedEntry != newModifiedEntry } fun initLibraryEntry(libraryEntryId: String) { if (_libraryEntryWithModification.value != null) return _loadState.value = LoadState.Loading viewModelScope.launch(Dispatchers.IO) { val libraryEntry = getLibraryEntry(libraryEntryId) ?: run { _loadState.postValue(LoadState.CloseDialog) return@launch } _libraryEntry.postValue(libraryEntry) val libraryModification = libraryRepository.getLibraryEntryModification(libraryEntryId) ?: LibraryEntryModification.withIdAndNulls(libraryEntryId) val libraryEntryWrapper = LibraryEntryWithModification( libraryEntry, libraryModification ) uneditedLibraryEntryWrapper = libraryEntryWrapper.copy() _libraryEntryWithModification.postValue(libraryEntryWrapper) }.invokeOnCompletion { _loadState.postValue(LoadState.NotLoading) } } private suspend fun getLibraryEntry(libraryEntryId: String): LibraryEntry? { return try { libraryRepository.getLibraryEntryFromDatabase(libraryEntryId) ?: libraryRepository.fetchLibraryEntry( libraryEntryId, Filter().include("anime", "manga") ) } catch (e: Exception) { logE("Failed to obtain library entry.", e) return null } } fun setLibraryModification(libraryModification: LibraryEntryModification) { libraryEntryWithModification.value ?.copy(modification = libraryModification) ?.let { updatedWrapper -> _libraryEntryWithModification.value = updatedWrapper } } fun updateLibraryEntry(block: (LibraryEntryModification) -> LibraryEntryModification) { val updatedLibraryModification = libraryEntryWithModification.value?.modification?.let(block) if (updatedLibraryModification != null) { setLibraryModification(updatedLibraryModification) } } fun saveChanges() { val libraryModification = libraryEntryWithModification.value?.modification ?: return _loadState.value = LoadState.Loading viewModelScope.launch(Dispatchers.IO) { val result = updateLibraryEntryUseCase.invoke(libraryModification) when { result is Success -> _loadState.postValue(LoadState.CloseDialog) result is Failure && result.reason is NotFound -> _loadState.postValue(LoadState.CloseDialog) result is Failure -> _loadState.postValue(LoadState.Error) } } } fun removeLibraryEntry() { val libraryEntryId = libraryEntry.value?.id ?: return _loadState.value = LoadState.Loading viewModelScope.launch(Dispatchers.IO) { try { libraryRepository.removeLibraryEntry(libraryEntryId) _loadState.postValue(LoadState.CloseDialog) } catch (e: Exception) { logE("Failed to remove library entry.", e) _loadState.postValue(LoadState.Error) } } } enum class LoadState { NotLoading, Loading, Error, CloseDialog } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/main/HomeExploreFragment.kt ================================================ package io.github.drumber.kitsune.ui.main import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.annotation.StringRes import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.presentation.model.media.MediaSelector import io.github.drumber.kitsune.data.presentation.model.media.RequestType import io.github.drumber.kitsune.databinding.FragmentHomeExploreBinding import io.github.drumber.kitsune.databinding.SectionMainExploreBinding import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.adapter.OnItemClickListener import io.github.drumber.kitsune.ui.base.BaseFragment import io.github.drumber.kitsune.ui.component.ExploreSection import io.github.drumber.kitsune.ui.main.MainFragmentViewModel.NavigationAction import io.github.drumber.kitsune.util.network.ResponseData import kotlinx.coroutines.launch import org.koin.androidx.navigation.koinNavGraphViewModel class HomeExploreFragment : BaseFragment(R.layout.fragment_home_explore), OnItemClickListener { private var _binding: FragmentHomeExploreBinding? = null private val binding get() = _binding!! private val viewModel: MainFragmentViewModel by koinNavGraphViewModel(R.id.main_nav_graph) companion object { const val BUNDLE_MEDIA_TYPE = "bundle_media_type" } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentHomeExploreBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } val mediaType = arguments?.takeIf { it.containsKey(BUNDLE_MEDIA_TYPE) }?.let { it.getSerializable(BUNDLE_MEDIA_TYPE) as? MediaType } if (mediaType == MediaType.Anime) { initAnimeExploreSections() } else if (mediaType == MediaType.Manga) { initMangaExploreSections() } } private fun initAnimeExploreSections() { // trending buildExploreSectionView( MediaType.Anime, R.string.section_trending, Filter().limit(30), RequestType.TRENDING, binding.sectionTrending, viewModel.getAnimeExploreLiveData(MainFragmentViewModel.TRENDING) as LiveData>> ) // top airing buildExploreSectionView( MediaType.Anime, R.string.section_top_airing_anime, MainFragmentViewModel.FILTER_TOP_AIRING_ANIME, RequestType.ALL, binding.sectionTopAiring, viewModel.getAnimeExploreLiveData(MainFragmentViewModel.TOP_AIRING) as LiveData>> ) // top upcoming buildExploreSectionView( MediaType.Anime, R.string.section_top_upcoming_anime, MainFragmentViewModel.FILTER_TOP_UPCOMING_ANIME, RequestType.ALL, binding.sectionTopUpcoming, viewModel.getAnimeExploreLiveData(MainFragmentViewModel.TOP_UPCOMING) as LiveData>> ) // highest rated buildExploreSectionView( MediaType.Anime, R.string.section_highest_rated_anime, MainFragmentViewModel.FILTER_HIGHEST_RATED_ANIME, RequestType.ALL, binding.sectionHighestRated, viewModel.getAnimeExploreLiveData(MainFragmentViewModel.HIGHEST_RATED) as LiveData>> ) // most popular buildExploreSectionView( MediaType.Anime, R.string.section_most_popular_anime, MainFragmentViewModel.FILTER_MOST_POPULAR_ANIME, RequestType.ALL, binding.sectionMostPopular, viewModel.getAnimeExploreLiveData(MainFragmentViewModel.MOST_POPULAR) as LiveData>> ) } private fun initMangaExploreSections() { // trending buildExploreSectionView( MediaType.Manga, R.string.section_trending, Filter().limit(30), RequestType.TRENDING, binding.sectionTrending, viewModel.getMangaExploreLiveData(MainFragmentViewModel.TRENDING) as LiveData>> ) // top airing buildExploreSectionView( MediaType.Manga, R.string.section_top_airing_manga, MainFragmentViewModel.FILTER_TOP_AIRING_MANGA, RequestType.ALL, binding.sectionTopAiring, viewModel.getMangaExploreLiveData(MainFragmentViewModel.TOP_AIRING) as LiveData>> ) // top upcoming buildExploreSectionView( MediaType.Manga, R.string.section_top_upcoming_manga, MainFragmentViewModel.FILTER_TOP_UPCOMING_MANGA, RequestType.ALL, binding.sectionTopUpcoming, viewModel.getMangaExploreLiveData(MainFragmentViewModel.TOP_UPCOMING) as LiveData>> ) // highest rated buildExploreSectionView( MediaType.Manga, R.string.section_highest_rated_manga, MainFragmentViewModel.FILTER_HIGHEST_RATED_MANGA, RequestType.ALL, binding.sectionHighestRated, viewModel.getMangaExploreLiveData(MainFragmentViewModel.HIGHEST_RATED) as LiveData>> ) // most popular buildExploreSectionView( MediaType.Manga, R.string.section_most_popular_manga, MainFragmentViewModel.FILTER_MOST_POPULAR_MANGA, RequestType.ALL, binding.sectionMostPopular, viewModel.getMangaExploreLiveData(MainFragmentViewModel.MOST_POPULAR) as LiveData>> ) } private fun buildExploreSectionView( mediaType: MediaType, @StringRes titleRes: Int, filter: Filter, requestType: RequestType, sectionBinding: SectionMainExploreBinding, liveData: LiveData>> ): ExploreSection { sectionBinding.apply { rvMedia.isVisible = false rvMedia.uniqueId = sectionBinding.root.id xor mediaType.ordinal layoutLoading.apply { root.layoutParams.height = resources.getDimensionPixelSize(KitsunePref.mediaItemSize.heightRes) tvError.isVisible = false btnRetry.isVisible = false root.isVisible = true } } val mediaSelector = MediaSelector(mediaType, filter.options, requestType) val section = createExploreSection(titleRes, mediaSelector, sectionBinding.root) liveData.observe(viewLifecycleOwner) { response -> when (response) { is ResponseData.Success if response.data.isNotEmpty() -> { section.setData(response.data) sectionBinding.apply { layoutLoading.root.isVisible = false rvMedia.isVisible = true } } is ResponseData.Success if response.data.isEmpty() -> { sectionBinding.apply { rvMedia.isVisible = false layoutLoading.apply { root.isVisible = true tvError.isVisible = false tvNoData.isVisible = true progressBar.isVisible = false } } } else -> { sectionBinding.apply { rvMedia.isVisible = false layoutLoading.apply { root.isVisible = true tvError.isVisible = true tvNoData.isVisible = false progressBar.isVisible = false } } } } } return section } private fun createExploreSection( @StringRes titleRes: Int, mediaSelector: MediaSelector, view: View ): ExploreSection { val title = getString(titleRes) val glide = Glide.with(this) val section = ExploreSection(glide, title, null, this) { viewLifecycleOwner.lifecycleScope.launch { viewModel.navigate(NavigationAction.OpenMediaList(mediaSelector, title)) } } section.bindView(view) return section } override fun onItemClick(view: View, item: Media) { viewLifecycleOwner.lifecycleScope.launch { viewModel.navigate(NavigationAction.OpenMediaDetails(item.toMediaDto(), view)) } } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/main/HomeExploreViewPagerAdapter.kt ================================================ package io.github.drumber.kitsune.ui.main import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.viewpager2.adapter.FragmentStateAdapter import io.github.drumber.kitsune.data.common.media.MediaType class HomeExploreViewPagerAdapter(fragment: Fragment) : FragmentStateAdapter(fragment.childFragmentManager, fragment.viewLifecycleOwner.lifecycle) { override fun getItemCount() = 2 override fun createFragment(position: Int): Fragment { return when (position) { 0 -> HomeExploreFragment().apply { arguments = bundleOf(HomeExploreFragment.BUNDLE_MEDIA_TYPE to MediaType.Anime) } 1 -> HomeExploreFragment().apply { arguments = bundleOf(HomeExploreFragment.BUNDLE_MEDIA_TYPE to MediaType.Manga) } else -> throw IllegalStateException("Invalid position '$position'. There are ony 2 fragments!") } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/main/MainActivity.kt ================================================ package io.github.drumber.kitsune.ui.main import android.content.Intent import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.os.Bundle import android.view.View import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.core.graphics.Insets import androidx.core.view.ViewCompat import androidx.core.view.isVisible import androidx.core.view.iterator import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.NavigationUI import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.navigation.NavigationBarView import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback import io.github.drumber.kitsune.BuildConfig import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.IntentAction.OPEN_LIBRARY import io.github.drumber.kitsune.constants.IntentAction.OPEN_MEDIA import io.github.drumber.kitsune.constants.IntentAction.SHORTCUT_LIBRARY import io.github.drumber.kitsune.constants.IntentAction.SHORTCUT_SEARCH import io.github.drumber.kitsune.constants.IntentAction.SHORTCUT_SETTINGS import io.github.drumber.kitsune.databinding.ActivityMainBinding import io.github.drumber.kitsune.domain.work.UpdateLibraryWidgetUseCase import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.preference.StartPagePref import io.github.drumber.kitsune.preference.getDestinationId import io.github.drumber.kitsune.ui.authentication.AuthenticationActivity import io.github.drumber.kitsune.ui.base.BaseActivity import io.github.drumber.kitsune.ui.details.DetailsFragmentArgs import io.github.drumber.kitsune.ui.details.DetailsFragmentDirections import io.github.drumber.kitsune.ui.onboarding.OnboardingActivity import io.github.drumber.kitsune.ui.permissions.requestNotificationPermission import io.github.drumber.kitsune.ui.permissions.showNotificationPermissionRejectedDialog import io.github.drumber.kitsune.util.extensions.setStatusBarColorRes import io.github.drumber.kitsune.util.ui.RoundBitmapDrawable import io.github.drumber.kitsune.util.ui.getSystemBarsAndCutoutInsets import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel class MainActivity : BaseActivity() { private val viewModel: MainActivityViewModel by viewModel() private lateinit var binding: ActivityMainBinding private val updateLibraryWidget by inject() private lateinit var navController: NavController private var overrideStartDestination: Int? = null private var handledIntentHashCode: Int? = null private val navigationBarView: NavigationBarView get() = binding.bottomNavigation ?: binding.navigationRail ?: error("There must exist a navigation bar view.") override fun onCreate(savedInstanceState: Bundle?) { setExitSharedElementCallback(MaterialContainerTransformSharedElementCallback()) window.sharedElementsUseOverlay = false super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.reLoginPrompt.collectLatest { promptUserReLogin() } } } val initialLoginState = viewModel.isLoggedIn() lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.isLoggedInFlow.collectLatest { isLoggedIn -> if (initialLoginState != isLoggedIn) { updateLibraryWidget(this@MainActivity) startNewMainActivity() } } } } val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment navHostFragment.childFragmentManager.registerFragmentLifecycleCallbacks(object : FragmentLifecycleCallbacks() { private fun updateDecorationForFragment(fragment: Fragment) { var statusBarColorRes = android.R.color.transparent if (fragment is FragmentDecorationPreference && !fragment.hasTransparentStatusBar) { statusBarColorRes = R.color.translucent_status_bar } setStatusBarColorRes(statusBarColorRes) } override fun onFragmentStarted(fm: FragmentManager, f: Fragment) { updateDecorationForFragment(f) } override fun onFragmentDetached(fm: FragmentManager, f: Fragment) { fm.fragments.lastOrNull()?.let { updateDecorationForFragment(it) } } }, true) ViewCompat.setOnApplyWindowInsetsListener(binding.navHostFragment) { _, windowInsets -> if (!isNavigationBarViewVisible()) return@setOnApplyWindowInsetsListener windowInsets val insets = windowInsets.getSystemBarsAndCutoutInsets() val consumedInsets = binding.bottomNavigation?.applyWindowInsets(insets) ?: binding.navigationRail?.applyWindowInsets(insets) ?: Insets.of(0, 0, 0, 0) // consume insets used by the navigation bar view // and propagate the remaining inset space to child fragments windowInsets.inset(consumedInsets) } navController = navHostFragment.navController navigationBarView.apply { setOnItemSelectedListener { item -> viewModel.currentNavRootDestId = item.itemId // handle reselect of navigation item and pass event to current fragment navHostFragment.childFragmentManager.fragments.let { fragments -> if (item.itemId == selectedItemId && fragments.size > 0 && fragments[0] is NavigationBarView.OnItemReselectedListener && fragments[0].lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) ) { (fragments[0] as NavigationBarView.OnItemReselectedListener).onNavigationItemReselected( item ) } } if (item.itemId == selectedItemId) { // no need to navigate if we are already at the selected destination return@setOnItemSelectedListener true } // navigate to the target destination NavigationUI.onNavDestinationSelected(item, navController) } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.localUser .map { it?.avatar?.originalOrDown() } .distinctUntilChanged() .collectLatest { avatarUrl -> if (avatarUrl.isNullOrBlank()) { menu.findItem(R.id.profile_fragment) .setIcon(R.drawable.selector_profile) return@collectLatest } Glide.with(this@MainActivity) .asBitmap() .load(avatarUrl) .dontAnimate() .into(object : CustomTarget() { override fun onResourceReady( resource: Bitmap, transition: Transition? ) { menu.findItem(R.id.profile_fragment).icon = RoundBitmapDrawable(resource) } override fun onLoadCleared(placeholder: Drawable?) {} }) } } } } navController.addOnDestinationChangedListener { _, destination, _ -> // set the selected bottom navigation item for (menuItem in navigationBarView.menu) { if (menuItem.itemId == destination.id) { viewModel.currentNavRootDestId = menuItem.itemId } if (menuItem.itemId == viewModel.currentNavRootDestId) { menuItem.isChecked = true } } // hide bottom navigation if the destination is not a main one toggleNavigationBarView( !isDestinationOnMainNavGraph(destination) || destination.id == R.id.webViewFragment, lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) ) } handledIntentHashCode = when (savedInstanceState?.containsKey(LAST_HANDLED_INTENT_KEY)) { true -> savedInstanceState.getInt(LAST_HANDLED_INTENT_KEY) else -> null } if (savedInstanceState == null) { onCreateWithoutSavedInstanceState() } } private fun onCreateWithoutSavedInstanceState() { // override start fragment, but only on clean launch and when not launched by a deep link if (!isLaunchedByDeepLink()) { overrideStartDestination = getShortcutStartDestinationId() // if the app wasn't launched from an app shortcut // and the user has specified a custom start page // then set the start fragment to the custom one if (overrideStartDestination == null && KitsunePref.startFragment != StartPagePref.Home) { overrideStartDestination = KitsunePref.startFragment.getDestinationId() } } if (shouldStartOnboarding()) { startOnboardingActivity() } else if (KitsunePref.checkForUpdatesOnStart) { requestRequiredPermissions() } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) handledIntentHashCode?.let { outState.putInt(LAST_HANDLED_INTENT_KEY, it) } } override fun onStart() { super.onStart() if (!handleIntentAction(intent)) { overrideStartDestination?.let { navigateToSingleTopDestination(it) overrideStartDestination = null } } } private fun requestRequiredPermissions() { if (!KitsunePref.flagUserDeniedNotificationPermission) { val requestNotificationPermissionLauncher = registerForActivityResult(RequestPermission()) { isGranted -> if (isGranted) { KitsunePref.flagUserDeniedNotificationPermission = false } else { KitsunePref.checkForUpdatesOnStart = false KitsunePref.flagUserDeniedNotificationPermission = true showNotificationPermissionRejectedDialog() } } requestNotificationPermission(requestNotificationPermissionLauncher) { KitsunePref.flagUserDeniedNotificationPermission = true } } } private fun promptUserReLogin() { val intent = Intent(this, AuthenticationActivity::class.java) intent.putExtra(AuthenticationActivity.EXTRA_LOGGED_OUT, true) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP startActivity(intent) } private fun startNewMainActivity() { val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } private fun shouldStartOnboarding(): Boolean { return !BuildConfig.INSTRUMENTED_TEST && KitsunePref.onboardingFinishedVersionCode == -1 } private fun startOnboardingActivity() { val intent = Intent(this, OnboardingActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) } override fun onSupportNavigateUp(): Boolean { return navController.navigateUp() || super.onSupportNavigateUp() } /** Checks if the activity was launched using an app link, */ private fun isLaunchedByDeepLink(): Boolean { return intent.action == Intent.ACTION_VIEW && intent.data != null } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) if (!navController.handleDeepLink(intent)) { handleIntentAction(intent) } } private fun handleIntentAction(intent: Intent): Boolean { if (handledIntentHashCode == intent.filterHashCode()) return false handledIntentHashCode = intent.filterHashCode() return when (intent.action) { OPEN_MEDIA -> { val argsResult = intent.extras?.runCatching { DetailsFragmentArgs.fromBundle(this) } argsResult?.getOrNull()?.let { args -> val action = DetailsFragmentDirections.actionGlobalDetailsFragment( media = args.media, type = args.type, slug = args.slug ) navController.navigate(action) true } ?: false } OPEN_LIBRARY -> { navigateToSingleTopDestination(R.id.library_fragment) true } else -> false } } private fun getShortcutStartDestinationId(): Int? { return when (intent.action) { SHORTCUT_LIBRARY -> R.id.library_fragment SHORTCUT_SEARCH -> R.id.search_fragment SHORTCUT_SETTINGS -> R.id.settings_nav_graph else -> null } } private fun navigateToSingleTopDestination(navigationId: Int) { val navOptions = NavOptions.Builder() .setLaunchSingleTop(true) .setRestoreState(true) .setPopUpTo(R.id.main_fragment, inclusive = false, saveState = true) .build() navController.navigate(navigationId, null, navOptions) } private fun isDestinationOnMainNavGraph(destination: NavDestination): Boolean { return destination.parent?.id == R.id.main_nav_graph } private fun isNavigationBarViewVisible(): Boolean { return navController.currentDestination ?.let { isDestinationOnMainNavGraph(it) } ?: true } private fun toggleNavigationBarView(hideNavigationBar: Boolean, animate: Boolean = true) { if (!animate) { navigationBarView.isVisible = !hideNavigationBar } else { when { binding.bottomNavigation != null -> animateBottomNavigation(hideNavigationBar) binding.navigationRail != null -> animateNavigationRail(hideNavigationBar) } } } private fun animateBottomNavigation(slideDown: Boolean) { binding.bottomNavigation?.apply { if (slideDown) { animate().translationY(this.height.toFloat()) .withEndAction { this.isVisible = false } .duration = resources.getInteger(R.integer.bottom_navigation_animation_duration) .toLong() } else { animate().translationY(0f) .withStartAction { this.isVisible = true } .duration = resources.getInteger(R.integer.bottom_navigation_animation_duration) .toLong() } } } private fun animateNavigationRail(slideOut: Boolean) { binding.navigationRail?.apply { if (slideOut) { // different direction depending on if rail is left or right aligned val isRtl = layoutDirection == View.LAYOUT_DIRECTION_RTL val translationFactor = if (isRtl) 1 else -1 animate().translationX(this.width.toFloat() * translationFactor) .withEndAction { this.isVisible = false } .duration = resources.getInteger(R.integer.navigation_rail_animation_duration) .toLong() } else { animate().translationX(0f) .withStartAction { this.isVisible = true } .duration = resources.getInteger(R.integer.navigation_rail_animation_duration) .toLong() } } } private fun BottomNavigationView.applyWindowInsets(insets: Insets): Insets { updatePadding(left = insets.left, right = insets.right, bottom = insets.bottom) return Insets.of(0, 0, 0, insets.bottom) } private fun NavigationRailView.applyWindowInsets(insets: Insets): Insets { val isRtl = layoutDirection == View.LAYOUT_DIRECTION_RTL val left = if (!isRtl) insets.left else 0 val right = if (isRtl) insets.right else 0 updatePadding(left = left, top = insets.top, right = right, bottom = insets.bottom) return Insets.of(left, 0, right, 0) } companion object { private const val LAST_HANDLED_INTENT_KEY = "last_handled_intent" } } interface FragmentDecorationPreference { val hasTransparentStatusBar: Boolean get() = true } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/main/MainActivityViewModel.kt ================================================ package io.github.drumber.kitsune.ui.main import androidx.annotation.IdRes import androidx.lifecycle.ViewModel import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.repository.AccessTokenRepository.AccessTokenState import io.github.drumber.kitsune.data.repository.UserRepository import kotlinx.coroutines.flow.map class MainActivityViewModel( userRepository: UserRepository, private val accessTokenRepository: AccessTokenRepository ) : ViewModel() { /** Destination ID of the current selected bottom navigation item. */ @IdRes var currentNavRootDestId: Int = R.id.main_fragment val reLoginPrompt = userRepository.userReLogInPrompt val localUser = userRepository.localUser val isLoggedInFlow = accessTokenRepository.accessTokenState.map { it == AccessTokenState.PRESENT } fun isLoggedIn() = accessTokenRepository.accessTokenState.value == AccessTokenState.PRESENT } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/main/MainFragment.kt ================================================ package io.github.drumber.kitsune.ui.main import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.view.doOnPreDraw import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import com.google.android.material.navigation.NavigationBarView import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.tabs.TabLayoutMediator import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.FragmentMainBinding import io.github.drumber.kitsune.ui.main.MainFragmentViewModel.NavigationAction import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.extensions.recyclerView import io.github.drumber.kitsune.util.extensions.setAppTheme import io.github.drumber.kitsune.util.ui.initMarginWindowInsetsListener import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import kotlinx.coroutines.launch import org.koin.androidx.navigation.koinNavGraphViewModel class MainFragment : Fragment(R.layout.fragment_main), NavigationBarView.OnItemReselectedListener { private var _binding: FragmentMainBinding? = null private val binding get() = _binding!! private val viewModel: MainFragmentViewModel by koinNavGraphViewModel(R.id.main_nav_graph) override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentMainBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } initExploreViewPager() binding.appBarLayout.statusBarForeground = MaterialShapeDrawable.createWithElevationOverlay(context) binding.toolbar.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) binding.tabLayoutExplore.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) binding.swipeRefreshLayout.initMarginWindowInsetsListener( left = true, right = true, consume = false ) binding.nsvContent.initPaddingWindowInsetsListener(bottom = true, consume = false) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.reloadFinished.collect { isReloadFinished -> binding.swipeRefreshLayout.isRefreshing = !isReloadFinished } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.navigationAction.collect(::handleNavigationAction) } } } private fun handleNavigationAction(navigationAction: NavigationAction) { when (navigationAction) { is NavigationAction.OpenMediaList -> { val action = MainFragmentDirections.actionMainFragmentToMediaListFragment(navigationAction.mediaSelector, navigationAction.title) findNavController().navigateSafe(R.id.main_fragment, action) } is NavigationAction.OpenMediaDetails -> { val action = MainFragmentDirections.actionMainFragmentToDetailsFragment(navigationAction.mediaDto) val detailsTransitionName = getString(R.string.details_poster_transition_name) val extras = FragmentNavigatorExtras(navigationAction.sharedElement to detailsTransitionName) findNavController().navigateSafe(R.id.main_fragment, action, extras) } } } private fun initExploreViewPager() { binding.viewPagerExplore.apply { adapter = HomeExploreViewPagerAdapter(this@MainFragment) recyclerView.isNestedScrollingEnabled = false } TabLayoutMediator(binding.tabLayoutExplore, binding.viewPagerExplore) { tab, position -> when (position) { 0 -> { tab.text = getString(R.string.anime) } 1 -> { tab.text = getString(R.string.manga) } } }.attach() binding.swipeRefreshLayout.apply { setAppTheme() isRefreshing = isRefreshing && viewModel.isSomeEntryReloading() setOnRefreshListener { when (binding.viewPagerExplore.currentItem) { 0 -> viewModel.refreshAnimeData() 1 -> viewModel.refreshMangaData() } } } } override fun onNavigationItemReselected(item: MenuItem) { binding.nsvContent.smoothScrollTo(0, 0) binding.appBarLayout.setExpanded(true) } override fun onDestroyView() { _binding?.viewPagerExplore?.adapter = null super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/main/MainFragmentViewModel.kt ================================================ package io.github.drumber.kitsune.ui.main import android.view.View import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.liveData import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.constants.Defaults import io.github.drumber.kitsune.constants.SortFilter import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.presentation.dto.MediaDto import io.github.drumber.kitsune.data.presentation.model.media.MediaSelector import io.github.drumber.kitsune.data.presentation.model.media.identifier import io.github.drumber.kitsune.data.repository.AnimeRepository import io.github.drumber.kitsune.data.repository.MangaRepository import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.logV import io.github.drumber.kitsune.util.network.ResponseData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class MainFragmentViewModel( private val animeRepository: AnimeRepository, private val mangaRepository: MangaRepository ) : ViewModel() { private val _navigationAction = MutableSharedFlow() val navigationAction = _navigationAction.asSharedFlow() suspend fun navigate(action: NavigationAction) = withContext(Dispatchers.Main) { _navigationAction.emit(action) } private val animeReload = MutableLiveData(Any()) private val mangaReload = MutableLiveData(Any()) // Contains a boolean value for each explore section type which represents // whether the entry is reloading from network or not. private var animeReloadMap = mutableMapOf() private var mangaReloadMap = mutableMapOf() private val _reloadFinished = MutableSharedFlow() val reloadFinished = _reloadFinished.asSharedFlow() private val animeExploreSections = mutableMapOf( // trending createAnimeExploreEntry(TRENDING) { animeRepository.getTrending(Filter().limit(10)) }, // top airing createAnimeExploreEntry(TOP_AIRING) { animeRepository.getAllAnime(FILTER_TOP_AIRING_ANIME) }, // top upcoming createAnimeExploreEntry(TOP_UPCOMING) { animeRepository.getAllAnime(FILTER_TOP_UPCOMING_ANIME) }, // highest rated createAnimeExploreEntry(HIGHEST_RATED) { animeRepository.getAllAnime(FILTER_HIGHEST_RATED_ANIME) }, // most popular createAnimeExploreEntry(MOST_POPULAR) { animeRepository.getAllAnime(FILTER_MOST_POPULAR_ANIME) } ) private val mangaExploreSections = mutableMapOf( // trending createMangaExploreEntry(TRENDING) { mangaRepository.getTrending(Filter().limit(10)) }, // top airing createMangaExploreEntry(TOP_AIRING) { mangaRepository.getAllManga(FILTER_TOP_AIRING_MANGA) }, // top upcoming createMangaExploreEntry(TOP_UPCOMING) { mangaRepository.getAllManga(FILTER_TOP_UPCOMING_MANGA) }, // highest rated createMangaExploreEntry(HIGHEST_RATED) { mangaRepository.getAllManga(FILTER_HIGHEST_RATED_MANGA) }, // most popular createMangaExploreEntry(MOST_POPULAR) { mangaRepository.getAllManga(FILTER_MOST_POPULAR_MANGA) } ) fun getAnimeExploreLiveData(type: String) = animeExploreSections[type] ?: throw IllegalArgumentException("There is no anime live data for type ''$type.") fun getMangaExploreLiveData(type: String) = mangaExploreSections[type] ?: throw IllegalArgumentException("There is no manga live data for type ''$type.") fun refreshAnimeData() { if (!animeReloadMap.containsValue(true)) { animeReload.postValue(Any()) } } fun refreshMangaData() { if (!mangaReloadMap.containsValue(true)) { mangaReload.postValue(Any()) } } private fun createAnimeExploreEntry(key: String, call: suspend () -> List?) = Pair( key, animeReload.switchMap { liveData(Dispatchers.IO) { animeReloadMap[key] = true val responseData = processCall(call) emit(responseData) animeReloadMap[key] = false onEntryReloadFinished() } } ).also { animeReloadMap[key] = false } private fun createMangaExploreEntry(key: String, call: suspend () -> List?) = Pair( key, mangaReload.switchMap { liveData(Dispatchers.IO) { mangaReloadMap[key] = true val responseData = processCall(call) mangaReloadMap[key] = false onEntryReloadFinished() emit(responseData) } } ).also { mangaReloadMap[key] = false } private fun onEntryReloadFinished() { logV("isSomeEntryReloading: ${isSomeEntryReloading()} " + "Remaining: " + "Anime: " + animeReloadMap.count { it.value } + " Manga: " + mangaReloadMap.count { it.value } ) if (!isSomeEntryReloading()) { viewModelScope.launch(Dispatchers.Main) { _reloadFinished.emit(true) } } } fun isSomeEntryReloading(): Boolean { return animeReloadMap.containsValue(true) || mangaReloadMap.containsValue(true) } private suspend fun processCall(call: suspend () -> List?): ResponseData> { return try { val data = call() ?: throw NoDataException("Received data is 'null'.") ResponseData.Success(data) } catch (e: Exception) { logE("Failed to load data.", e) ResponseData.Error(e) } } sealed interface NavigationAction { data class OpenMediaList(val mediaSelector: MediaSelector, val title: String) : NavigationAction data class OpenMediaDetails(val mediaDto: MediaDto, val sharedElement: View) : NavigationAction } companion object { const val TRENDING = "anime_trending" const val TOP_AIRING = "top_airing" const val TOP_UPCOMING = "top_upcoming" const val HIGHEST_RATED = "highest_rated" const val MOST_POPULAR = "most_popular" val FILTER_TOP_AIRING_ANIME get() = createFilter(MediaType.Anime, "current") val FILTER_TOP_UPCOMING_ANIME get() = createFilter(MediaType.Anime, "upcoming") val FILTER_HIGHEST_RATED_ANIME get() = createFilter(MediaType.Anime, sortBy = SortFilter.AVERAGE_RATING_DESC) val FILTER_MOST_POPULAR_ANIME get() = createFilter(MediaType.Anime, sortBy = SortFilter.POPULARITY_DESC) val FILTER_TOP_AIRING_MANGA get() = createFilter(MediaType.Manga, "current") val FILTER_TOP_UPCOMING_MANGA get() = createFilter(MediaType.Manga, "upcoming") val FILTER_HIGHEST_RATED_MANGA get() = createFilter(MediaType.Manga, sortBy = SortFilter.AVERAGE_RATING_DESC) val FILTER_MOST_POPULAR_MANGA get() = createFilter(MediaType.Manga, sortBy = SortFilter.POPULARITY_DESC) private fun createFilter( type: MediaType, filterType: String? = null, sortBy: SortFilter = SortFilter.POPULARITY_DESC ) = Filter().apply { pageLimit(10) filterType?.let { filter("status", it) } sort(sortBy.queryParam) fields(type.identifier, *Defaults.MINIMUM_COLLECTION_FIELDS) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/medialist/MediaListFragment.kt ================================================ package io.github.drumber.kitsune.ui.medialist import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.view.doOnPreDraw import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.paging.LoadState import androidx.paging.PagingData import androidx.paging.PagingDataAdapter import com.bumptech.glide.Glide import com.google.android.material.color.MaterialColors import com.google.android.material.navigation.NavigationBarView import com.google.android.material.transition.MaterialSharedAxis import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.FragmentMediaListBinding import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.adapter.paging.AnimeAdapter import io.github.drumber.kitsune.ui.adapter.paging.MangaAdapter import io.github.drumber.kitsune.ui.adapter.paging.ResourceLoadStateAdapter import io.github.drumber.kitsune.ui.component.LoadStateSpanSizeLookup import io.github.drumber.kitsune.ui.component.ResponsiveGridLayoutManager import io.github.drumber.kitsune.ui.component.updateLoadState import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class MediaListFragment : Fragment(R.layout.fragment_media_list), NavigationBarView.OnItemReselectedListener { private val args: MediaListFragmentArgs by navArgs() private var _binding: FragmentMediaListBinding? = null private val binding get() = _binding!! private val viewModel: MediaListViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentMediaListBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() if (findNavController().currentBackStackEntry?.arguments == null) { view.doOnPreDraw { startPostponedEnterTransition() } } else { // safeguard: ensure startPostponedEnterTransition() got called within 200ms view.postDelayed({ startPostponedEnterTransition() }, 200) } val colorBackground = MaterialColors.getColor(view, android.R.attr.colorBackground) view.setBackgroundColor(colorBackground) binding.rvMedia.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) viewModel.setMediaSelector(args.mediaSelector) binding.collapsingToolbar.initWindowInsetsListener(consume = false) binding.toolbar.apply { initWindowInsetsListener(consume = false) title = args.title setNavigationOnClickListener { findNavController().navigateUp() } } initRecyclerView() } private fun initRecyclerView() { val glide = Glide.with(this) val adapter = when (args.mediaSelector.mediaType) { MediaType.Anime -> AnimeAdapter(glide, this::onMediaClicked) MediaType.Manga -> MangaAdapter(glide, this::onMediaClicked) } as PagingDataAdapter val columnWidth = resources.getDimension(KitsunePref.mediaItemSize.widthRes) + 2 * resources.getDimension(R.dimen.media_item_margin) val gridLayout = ResponsiveGridLayoutManager(requireContext(), columnWidth.toInt(), 2) gridLayout.spanSizeLookup = LoadStateSpanSizeLookup(adapter, gridLayout) binding.rvMedia.adapter = adapter.withLoadStateHeaderAndFooter( header = ResourceLoadStateAdapter(adapter), footer = ResourceLoadStateAdapter(adapter) ) binding.rvMedia.layoutManager = gridLayout binding.layoutLoading.btnRetry.setOnClickListener { adapter.retry() } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { adapter.loadStateFlow.collectLatest { loadStates -> binding.layoutLoading.updateLoadState( binding.rvMedia, adapter.itemCount, loadStates ) if (loadStates.refresh is LoadState.NotLoading) { binding.rvMedia.doOnPreDraw { startPostponedEnterTransition() } } } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.dataSource.collectLatest { data -> adapter.submitData(data as PagingData) } } } } fun onMediaClicked(view: View, model: Media) { val action = MediaListFragmentDirections.actionMediaListFragmentToDetailsFragment(model.toMediaDto()) val detailsTransitionName = getString(R.string.details_poster_transition_name) val extras = FragmentNavigatorExtras(view to detailsTransitionName) findNavController().navigateSafe(R.id.media_list_fragment, action, extras) } override fun onNavigationItemReselected(item: MenuItem) { if (binding.rvMedia.canScrollVertically(-1)) { binding.rvMedia.smoothScrollToPosition(0) binding.appBarLayout.setExpanded(true) } else { findNavController().navigateUp() } } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/medialist/MediaListViewModel.kt ================================================ package io.github.drumber.kitsune.ui.medialist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import io.github.drumber.kitsune.constants.Defaults import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.FilterOptions import io.github.drumber.kitsune.data.common.media.MediaType import io.github.drumber.kitsune.data.common.toFilter import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.presentation.model.media.MediaSelector import io.github.drumber.kitsune.data.presentation.model.media.RequestType import io.github.drumber.kitsune.data.presentation.model.media.identifier import io.github.drumber.kitsune.data.repository.AnimeRepository import io.github.drumber.kitsune.data.repository.MangaRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class MediaListViewModel( private val animeRepository: AnimeRepository, private val mangaRepository: MangaRepository ) : ViewModel() { private val mediaSelectorFlow: StateFlow val setMediaSelector: (MediaSelector) -> Unit init { val mutableMediaSelectorFlow = MutableSharedFlow(replay = 1) mediaSelectorFlow = mutableMediaSelectorFlow .distinctUntilChanged() .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = null ) setMediaSelector = { mediaSelector -> viewModelScope.launch { mutableMediaSelectorFlow.emit(mediaSelector) } } } @OptIn(ExperimentalCoroutinesApi::class) val dataSource: Flow> = mediaSelectorFlow .filterNotNull() .distinctUntilChanged() .flatMapLatest { selector -> // copy the filter and limit the fields of the response model to only the required ones val mediaSelector = with(selector) { copy( filterOptions = Filter(filterOptions.toMutableMap()) .fields(mediaType.identifier, *Defaults.MINIMUM_COLLECTION_FIELDS) .options ) } getData(mediaSelector) }.cachedIn(viewModelScope) private fun getData(mediaSelector: MediaSelector): Flow> { return when (mediaSelector.mediaType) { MediaType.Anime -> getAnimeData(mediaSelector.requestType, mediaSelector.filterOptions) MediaType.Manga -> getMangaData(mediaSelector.requestType, mediaSelector.filterOptions) } as Flow> } private fun getAnimeData(type: RequestType, filterOptions: FilterOptions) = when (type) { RequestType.ALL -> animeRepository.animePager( filterOptions.toFilter(), Kitsu.DEFAULT_PAGE_SIZE ) RequestType.TRENDING -> animeRepository.trendingAnimePager( filterOptions.toFilter(), Kitsu.DEFAULT_PAGE_SIZE ) } private fun getMangaData(type: RequestType, filterOptions: FilterOptions) = when (type) { RequestType.ALL -> mangaRepository.mangaPager( filterOptions.toFilter(), Kitsu.DEFAULT_PAGE_SIZE ) RequestType.TRENDING -> mangaRepository.trendingMangaPager( filterOptions.toFilter(), Kitsu.DEFAULT_PAGE_SIZE ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/OnboardingActivity.kt ================================================ package io.github.drumber.kitsune.ui.onboarding import android.content.Intent import android.graphics.Color import android.net.Uri import android.os.Bundle import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Info import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.asFlow import com.chibatching.kotpref.livedata.asLiveData import io.github.drumber.kitsune.BuildConfig import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.authentication.AuthenticationActivity import io.github.drumber.kitsune.ui.base.BaseActivity import io.github.drumber.kitsune.ui.onboarding.pages.LoginPage import io.github.drumber.kitsune.ui.onboarding.pages.SetupPageAdapter import io.github.drumber.kitsune.ui.onboarding.pages.WelcomePage import io.github.drumber.kitsune.ui.theme.KitsuneTheme import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class OnboardingActivity : BaseActivity() { private val viewModel: OnboardingViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge( navigationBarStyle = SystemBarStyle.auto( Color.TRANSPARENT, Color.TRANSPARENT ) ) setContent { val useDynamicColorTheme by KitsunePref.asLiveData(KitsunePref::useDynamicColorTheme) .asFlow() .collectAsState(initial = KitsunePref.useDynamicColorTheme) val darkModePreference by KitsunePref.asLiveData(KitsunePref::darkMode) .asFlow() .collectAsState(initial = KitsunePref.darkMode) val isDarkModeEnabled = when (darkModePreference.toInt()) { AppCompatDelegate.MODE_NIGHT_NO -> false AppCompatDelegate.MODE_NIGHT_YES -> true else -> isSystemInDarkTheme() } val uiState by viewModel.uiSate.collectAsState() val localUser by viewModel.localUser.collectAsState() var openCreateAccountForwardDialog by remember { mutableStateOf(false) } KitsuneTheme(dynamicColor = useDynamicColorTheme, darkTheme = isDarkModeEnabled) { Scaffold( modifier = Modifier.fillMaxSize(), contentWindowInsets = WindowInsets.safeDrawing ) { innerPadding -> OnboardingTour( uiState = uiState, localUser = localUser, contentPadding = innerPadding, onNavigateToLogin = { startActivity(Intent(this, AuthenticationActivity::class.java)) }, onNavigateToCreateAccount = { openCreateAccountForwardDialog = true }, onFinish = { KitsunePref.onboardingFinishedVersionCode = BuildConfig.VERSION_CODE finish() } ) if (openCreateAccountForwardDialog) { CreateAccountForwardDialog( onDismissRequest = { openCreateAccountForwardDialog = false }, onConfirmation = { openCreateAccountForwardDialog = false startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(Kitsu.BASE_URL))) } ) } } } } } } @Composable private fun OnboardingTour( uiState: OnboardingUiState = OnboardingUiState(), localUser: LocalUser? = null, contentPadding: PaddingValues = PaddingValues(0.dp), onNavigateToLogin: () -> Unit = {}, onNavigateToCreateAccount: () -> Unit = {}, onFinish: () -> Unit = {} ) { val pagerState = rememberPagerState(pageCount = { 3 }) val contentPaddingWithoutBottom = PaddingValues( start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), top = contentPadding.calculateTopPadding(), end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), bottom = 0.dp ) val contentPaddingWithoutTop = PaddingValues( start = contentPadding.calculateStartPadding(LocalLayoutDirection.current), top = 0.dp, end = contentPadding.calculateEndPadding(LocalLayoutDirection.current), bottom = contentPadding.calculateBottomPadding() ) val coroutineScope = rememberCoroutineScope() Surface( color = MaterialTheme.colorScheme.surface ) { Column(modifier = Modifier) { HorizontalPager( state = pagerState, modifier = Modifier.weight(1f) ) { page -> when (page) { 0 -> WelcomePage( modifier = Modifier.padding(contentPaddingWithoutBottom), uiState = uiState, onNextClicked = { coroutineScope.launch { pagerState.animateScrollToPage(1) } } ) 1 -> LoginPage( modifier = Modifier.padding(contentPaddingWithoutBottom), localUser = localUser, onBack = { coroutineScope.launch { pagerState.animateScrollToPage(0) } }, onNext = { coroutineScope.launch { pagerState.animateScrollToPage(2) } }, onLoginClicked = onNavigateToLogin, onCreateAccountClicked = onNavigateToCreateAccount ) 2 -> SetupPageAdapter( modifier = Modifier.padding(contentPaddingWithoutBottom), localUser = localUser, onBack = { coroutineScope.launch { pagerState.animateScrollToPage(1) } }, onFinishClicked = onFinish ) } } PageIndicator( modifier = Modifier .fillMaxWidth() .padding(contentPaddingWithoutTop), pagerState = pagerState ) } } } @Composable private fun PageIndicator(modifier: Modifier, pagerState: PagerState) { Row( modifier .wrapContentHeight() .padding(bottom = 8.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.Bottom ) { repeat(pagerState.pageCount) { page -> val color = if (pagerState.currentPage == page) { MaterialTheme.colorScheme.primary } else { MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) } Box( Modifier .padding(4.dp) .clip(CircleShape) .background(color) .size(8.dp) ) } } } @Composable private fun CreateAccountForwardDialog( onDismissRequest: () -> Unit, onConfirmation: () -> Unit ) { AlertDialog( title = { Text(text = stringResource(R.string.onboarding_login_forward_dialog_title)) }, text = { Text( text = stringResource( R.string.onboarding_login_forward_dialog_message, Kitsu.API_HOST ) ) }, icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) }, onDismissRequest = onDismissRequest, confirmButton = { TextButton(onClick = onConfirmation) { Text(text = stringResource(R.string.action_ok)) } }, dismissButton = { TextButton(onClick = onDismissRequest) { Text(text = stringResource(R.string.action_cancel)) } } ) } @Preview(showBackground = true) @Composable private fun OnboardingTourPreview() { KitsuneTheme { OnboardingTour() } } @Preview(showBackground = true) @Composable private fun CreateAccountForwardDialogPreview() { KitsuneTheme { CreateAccountForwardDialog({}, {}) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/OnboardingUiState.kt ================================================ package io.github.drumber.kitsune.ui.onboarding import io.github.drumber.kitsune.data.source.local.user.model.LocalUser data class OnboardingUiState( val backgroundImages: List = emptyList(), val user: LocalUser? = null ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/OnboardingViewModel.kt ================================================ package io.github.drumber.kitsune.ui.onboarding import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.repository.AnimeRepository import io.github.drumber.kitsune.data.repository.MangaRepository import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class OnboardingViewModel( private val animeRepository: AnimeRepository, private val mangaRepository: MangaRepository, userRepository: UserRepository ) : ViewModel() { private val _uiState = MutableStateFlow(OnboardingUiState()) val uiSate = _uiState.asStateFlow() val localUser = userRepository.localUser init { loadPosterImages() } private fun loadPosterImages() { val baseFilter = Filter() .sort("-userCount") .fields("media", "posterImage") .pageLimit(10) val animeRandomOffset = (0..20).random() val mangaRandomOffset = (0..20).random() viewModelScope.launch(Dispatchers.IO) { val submitPosterImages = { posterImages: List -> _uiState.update { it.copy(backgroundImages = it.backgroundImages + posterImages) } } launch { fetchPosterImages { animeRepository.getAllAnime(baseFilter.copy().pageOffset(animeRandomOffset)) }?.let { posterImages -> submitPosterImages(posterImages) } } launch { fetchPosterImages { mangaRepository.getAllManga(baseFilter.copy().pageOffset(mangaRandomOffset)) }?.let { posterImages -> submitPosterImages(posterImages) } } } } private suspend fun fetchPosterImages(request: suspend () -> List?): List? { return try { request()?.mapNotNull { it.posterImage?.largeOrDown() } } catch (e: Exception) { logE("Failed to request poster images.", e) null } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/components/CustomDialog.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialogDefaults import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun CustomDialog( modifier: Modifier = Modifier, icon: @Composable (() -> Unit)? = null, title: (@Composable () -> Unit)? = null, onDismissRequest: () -> Unit, confirmButton: @Composable () -> Unit, dismissButton: @Composable (() -> Unit)?, shape: Shape = AlertDialogDefaults.shape, containerColor: Color = AlertDialogDefaults.containerColor, iconContentColor: Color = AlertDialogDefaults.iconContentColor, titleContentColor: Color = AlertDialogDefaults.titleContentColor, textContentColor: Color = AlertDialogDefaults.textContentColor, tonalElevation: Dp = AlertDialogDefaults.TonalElevation, properties: DialogProperties = DialogProperties(), content: @Composable (contentPadding: PaddingValues) -> Unit ) { val horizontalDialogPadding = PaddingValues( start = DialogPadding.calculateStartPadding(LocalLayoutDirection.current), end = DialogPadding.calculateEndPadding(LocalLayoutDirection.current) ) val contentScrollState = rememberScrollState() BasicAlertDialog( onDismissRequest = onDismissRequest, modifier = modifier, properties = properties ) { Surface( shape = shape, color = containerColor, tonalElevation = tonalElevation, ) { Column( modifier = Modifier.padding( top = DialogPadding.calculateTopPadding(), bottom = DialogPadding.calculateBottomPadding() ) ) { // Icon icon?.let { CompositionLocalProvider(LocalContentColor provides iconContentColor) { Box( modifier = Modifier .padding(horizontalDialogPadding) .padding(IconPadding) .align(Alignment.CenterHorizontally) ) { icon() } } } // Title title?.let { CompositionLocalProvider( LocalContentColor provides titleContentColor, LocalTextStyle provides MaterialTheme.typography.headlineSmall ) { Box( modifier = Modifier .padding(horizontalDialogPadding) .padding(TitlePadding) .align( if (icon == null) Alignment.Start else Alignment.CenterHorizontally ) ) { title() } } } // Top content scroll divider if (contentScrollState.canScrollBackward) { HorizontalDivider() } // Content CompositionLocalProvider( LocalContentColor provides textContentColor, LocalTextStyle provides MaterialTheme.typography.bodyMedium ) { Box( Modifier .weight(weight = 1f, fill = false) .verticalScroll(contentScrollState) .align(Alignment.Start) ) { content(horizontalDialogPadding) } } // Bottom content scroll divider if (contentScrollState.canScrollForward) { HorizontalDivider() } // Buttons Box( modifier = Modifier .padding(horizontalDialogPadding) .align(Alignment.End) ) { CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.primary, LocalTextStyle provides MaterialTheme.typography.labelLarge, ) { FlowRow( horizontalArrangement = Arrangement.End ) { dismissButton?.invoke() confirmButton() } } } } } } } private val DialogPadding = PaddingValues(all = 24.dp) private val IconPadding = PaddingValues(bottom = 16.dp) private val TitlePadding = PaddingValues(bottom = 16.dp) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/components/ImagePresenter.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.components interface ImagePresenter { fun hasNextImage(): Boolean fun getNextImage(): String? } object EmptyImagePresenter : ImagePresenter { override fun hasNextImage(): Boolean { return false } override fun getNextImage(): String? { return null } } class RandomImagePresenter(private val imageUrls: List) : ImagePresenter { private val lastShownImages = LinkedHashSet() override fun hasNextImage(): Boolean { return imageUrls.isNotEmpty() } override fun getNextImage(): String? { if (imageUrls.isEmpty()) { return null } if (imageUrls.size == 1) { return imageUrls.first() } val imagePool = imageUrls - lastShownImages if (imagePool.isEmpty()) { val lastImageUrl = lastShownImages.last() lastShownImages.clear() lastShownImages.add(lastImageUrl) return getNextImage() } val image = imagePool.random() lastShownImages.add(image) return image } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/components/ImageSlideshow.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.components import android.provider.Settings import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Ease import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import com.bumptech.glide.Glide import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import io.github.drumber.kitsune.ui.theme.KitsuneTheme import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlin.random.Random private const val ANIMATION_TRANSFORM_DURATION = 20000 @OptIn(ExperimentalGlideComposeApi::class) @Composable fun ImageSlideshow( modifier: Modifier = Modifier, imagePresenter: ImagePresenter ) { var currentImage by rememberSaveable { mutableStateOf(imagePresenter.getNextImage()) } var nextImage by rememberSaveable { mutableStateOf(imagePresenter.getNextImage()) } val context = LocalContext.current val scaleAnimation = remember { Animatable(1.1f) } val xAnimation = remember { Animatable(0f) } val yAnimation = remember { Animatable(0f) } var animationKey by remember { mutableIntStateOf(0) } if (isAnimationsEnabled()) { LaunchedEffect(animationKey) { awaitAll( async { scaleAnimation.animateTo( targetValue = 1.3f - scaleAnimation.value + 1.1f, animationSpec = tween(ANIMATION_TRANSFORM_DURATION, easing = Ease) ) }, async { xAnimation.animateTo( targetValue = (10f - xAnimation.value) * (if (Random.nextInt() % 2 == 0) 1 else -1), animationSpec = tween(ANIMATION_TRANSFORM_DURATION, easing = LinearEasing) ) }, async { yAnimation.animateTo( targetValue = (15f - yAnimation.value) * (if (Random.nextInt() % 2 == 0) 1 else -1), animationSpec = tween(ANIMATION_TRANSFORM_DURATION, easing = LinearEasing) ) } ) animationKey = (++animationKey) % 2 } } LaunchedEffect(currentImage) { // preload next image Glide.with(context) .load(nextImage) .preload() delay(8000) currentImage = nextImage nextImage = imagePresenter.getNextImage() } Crossfade( targetState = currentImage, label = "slide show", modifier = modifier.clipToBounds(), animationSpec = tween(3000) ) { image -> if (image != null) { GlideImage( model = image, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .fillMaxSize() .graphicsLayer { scaleX = scaleAnimation.value scaleY = scaleX translationX = xAnimation.value translationY = yAnimation.value transformOrigin = TransformOrigin.Center } ) } } } @Composable private fun isAnimationsEnabled(): Boolean { val context = LocalContext.current return remember { val windowAnimationScale = Settings.Global.getFloat( context.contentResolver, Settings.Global.WINDOW_ANIMATION_SCALE, 1f ) val transitionAnimationScale = Settings.Global.getFloat( context.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE, 1f ) val animatorDurationScale = Settings.Global.getFloat( context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f ) windowAnimationScale != 0f && transitionAnimationScale != 0f && animatorDurationScale != 0f } } @Preview(showBackground = true) @Composable fun ImageSlideshowPreview() { KitsuneTheme { ImageSlideshow(imagePresenter = EmptyImagePresenter) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/components/OnboardingNavigationControls.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.github.drumber.kitsune.R import io.github.drumber.kitsune.ui.theme.KitsuneTheme @Composable fun OnboardingNavigationControls( modifier: Modifier = Modifier, hideNextButton: Boolean = false, onBackClicked: () -> Unit = {}, onNextClicked: () -> Unit = {}, backText: String = stringResource(R.string.action_back), nextText: String = stringResource(R.string.action_next) ) { Row( horizontalArrangement = Arrangement.SpaceBetween, modifier = modifier.fillMaxWidth() ) { TextButton(onClick = onBackClicked) { Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) Spacer(Modifier.width(6.dp)) Text(backText) } if (!hideNextButton) { TextButton(onClick = onNextClicked) { Text(nextText) Spacer(Modifier.width(6.dp)) Icon(Icons.AutoMirrored.Default.ArrowForward, contentDescription = null) } } } } @Preview(showBackground = true) @Composable private fun OnboardingNavigationControlsPreview() { KitsuneTheme { OnboardingNavigationControls() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/components/PreferenceCard.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @Composable fun PreferenceCard( title: @Composable () -> Unit, description: @Composable () -> Unit, action: (@Composable () -> Unit)? = null, onClick: () -> Unit ) { Surface( onClick = onClick, color = MaterialTheme.colorScheme.surfaceContainer, shape = RoundedCornerShape(24.dp), modifier = Modifier .fillMaxWidth() ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() .padding(16.dp) ) { Column(modifier = Modifier.weight(1f, true)) { CompositionLocalProvider( LocalTextStyle provides LocalTextStyle.current.merge(MaterialTheme.typography.bodyLarge) ) { title() } Spacer(Modifier.height(2.dp)) CompositionLocalProvider( LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant, LocalTextStyle provides LocalTextStyle.current.merge(MaterialTheme.typography.bodyMedium) ) { description() } } if (action != null) { Spacer(Modifier.width(4.dp)) action() } } } } @Preview(showBackground = false) @Composable private fun PreferenceCardPreview() { PreferenceCard( title = { Text("Check for Updates") }, description = { Text("Get notified when a new release is available on GitHub.") }, action = { Switch( checked = false, onCheckedChange = {} ) }, onClick = {} ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/pages/LoginPage.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.pages import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowWidthSizeClass import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.ui.onboarding.components.OnboardingNavigationControls import io.github.drumber.kitsune.ui.theme.KitsuneTheme @Composable fun LoginPage( modifier: Modifier = Modifier, windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, localUser: LocalUser? = null, onLoginClicked: () -> Unit = {}, onCreateAccountClicked: () -> Unit = {}, onBack: () -> Unit = {}, onNext: () -> Unit = {} ) { val backgroundGradient = listOf( MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), MaterialTheme.colorScheme.surface ) Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() .background(Brush.verticalGradient(backgroundGradient)) .then(modifier) .padding(16.dp) ) { if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.weight(1f) ) { HeaderSection() Spacer(modifier = Modifier.height(32.dp)) if (localUser != null) { LoggedInUserSection( modifier = Modifier.fillMaxWidth(), localUser = localUser, onNextClicked = onNext ) } else { ActionSection( onLoginClicked = onLoginClicked, onCreateAccountClicked = onCreateAccountClicked ) } } } else { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.weight(1f) ) { HeaderSection(modifier = Modifier.fillMaxWidth(0.5f)) Spacer(modifier = Modifier.width(32.dp)) Column { if (localUser != null) { LoggedInUserSection( modifier = Modifier.fillMaxWidth(), localUser = localUser, onNextClicked = onNext ) } else { ActionSection( onLoginClicked = onLoginClicked, onCreateAccountClicked = onCreateAccountClicked ) } } } } Spacer(modifier = Modifier.height(8.dp)) OnboardingNavigationControls( hideNextButton = localUser != null, onBackClicked = onBack, onNextClicked = onNext, nextText = stringResource(R.string.action_skip) ) } } @Composable private fun HeaderSection(modifier: Modifier = Modifier) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { Row( modifier = Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { Image( painter = painterResource(R.drawable.onboarding_login_logo), contentDescription = null, modifier = Modifier .size(148.dp) ) } Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.onboarding_login_title), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.onboarding_login_subtitle), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) } } @OptIn(ExperimentalGlideComposeApi::class) @Composable private fun LoggedInUserSection( modifier: Modifier = Modifier, localUser: LocalUser, onNextClicked: () -> Unit = {} ) { Column(modifier = modifier) { Card( shape = CircleShape, modifier = Modifier.fillMaxWidth() ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp) ) { GlideImage( model = localUser.avatar?.originalOrDown(), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier .size(48.dp) .clip(CircleShape) .background(Color.Gray) ) { it.placeholder(R.drawable.profile_picture_placeholder) } Spacer(Modifier.width(16.dp)) Column { Text( style = MaterialTheme.typography.titleMedium, text = localUser.name ?: stringResource(R.string.no_information) ) Spacer(Modifier.height(2.dp)) Text( style = MaterialTheme.typography.bodySmall, text = stringResource(R.string.onboarding_login_is_logged_in) ) } } } Spacer(modifier = Modifier.height(8.dp)) Button( onClick = onNextClicked, modifier = Modifier.fillMaxWidth() ) { Text(text = stringResource(R.string.action_continue)) } } } @Composable private fun ActionSection( modifier: Modifier = Modifier, isDisabled: Boolean = false, onLoginClicked: () -> Unit = {}, onCreateAccountClicked: () -> Unit = {} ) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { Button( onClick = onLoginClicked, enabled = !isDisabled, modifier = Modifier.fillMaxWidth() ) { Text(text = stringResource(R.string.action_log_in)) } Spacer(modifier = Modifier.height(8.dp)) OutlinedButton( onClick = onCreateAccountClicked, enabled = !isDisabled, modifier = Modifier.fillMaxWidth() ) { Text(text = stringResource(R.string.action_create_account)) } } } @Preview(showBackground = true) @Composable private fun LoginPagePreview() { KitsuneTheme { LoginPage(localUser = null) } } @Preview(showBackground = true) @Composable private fun LoginPageAuthenticatedPreview() { KitsuneTheme { LoginPage(localUser = LocalUser.empty("")) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/pages/SetupPage.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.pages import android.Manifest import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.asFlow import com.chibatching.kotpref.livedata.asLiveData import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.shouldShowRationale import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.source.local.user.model.LocalTitleLanguagePreference import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.onboarding.components.CustomDialog import io.github.drumber.kitsune.ui.onboarding.components.OnboardingNavigationControls import io.github.drumber.kitsune.ui.onboarding.components.PreferenceCard import io.github.drumber.kitsune.ui.theme.KitsuneTheme import kotlinx.coroutines.flow.map @OptIn(ExperimentalPermissionsApi::class) @Composable fun SetupPageAdapter( modifier: Modifier = Modifier, localUser: LocalUser? = null, onFinishClicked: () -> Unit = {}, onBack: () -> Unit = {} ) { val notificationPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) } else { null } val isNotificationPermissionGranted = notificationPermissionState?.status?.isGranted ?: true val shouldShouldNotificationPermissionNotice = notificationPermissionState?.status?.shouldShowRationale ?: false val checkForUpdatesPreference by KitsunePref .asLiveData(KitsunePref::checkForUpdatesOnStart) .asFlow() .collectAsState(false) val updateCheckForUpdatesPreference: (Boolean) -> Unit = { value: Boolean -> if (isNotificationPermissionGranted) { KitsunePref.checkForUpdatesOnStart = value } else { notificationPermissionState?.launchPermissionRequest() } } val titleLanguages = LocalTitleLanguagePreference.entries.map { it.name } val selectedTitleLanguageIndex by KitsunePref.getTitleLanguageAsFlow() .map { it.ordinal } .collectAsState(KitsunePref.titles.ordinal) val selectTitleLanguage = { index: Int -> KitsunePref.titles = LocalTitleLanguagePreference.entries[index] } SetupPage( modifier = modifier, onFinishClicked = onFinishClicked, onBackClicked = onBack, showNotificationPermissionNotice = shouldShouldNotificationPermissionNotice, checkForUpdatesPreference = checkForUpdatesPreference, onCheckForUpdatesPreferenceChanged = updateCheckForUpdatesPreference, hideTitleLanguagePreference = localUser != null, titleLanguages = titleLanguages, selectedTitleLanguageIndex = selectedTitleLanguageIndex, onTitleLanguageSelected = selectTitleLanguage ) } @Composable private fun SetupPage( modifier: Modifier = Modifier, onFinishClicked: () -> Unit = {}, onBackClicked: () -> Unit = {}, showNotificationPermissionNotice: Boolean = false, checkForUpdatesPreference: Boolean = false, onCheckForUpdatesPreferenceChanged: (Boolean) -> Unit = {}, hideTitleLanguagePreference: Boolean = false, titleLanguages: List = emptyList(), selectedTitleLanguageIndex: Int = 0, onTitleLanguageSelected: (Int) -> Unit = {} ) { val backgroundGradient = listOf( MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), MaterialTheme.colorScheme.surface ) var openSelectTitleLanguageDialog by rememberSaveable { mutableStateOf(false) } if (openSelectTitleLanguageDialog) { SelectTitleLanguageDialog( titleLanguages = titleLanguages, selectedIndex = selectedTitleLanguageIndex, onTitleLanguageSelected = { onTitleLanguageSelected(it) openSelectTitleLanguageDialog = false }, onDismiss = { openSelectTitleLanguageDialog = false } ) } Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, modifier = Modifier .fillMaxSize() .background(Brush.verticalGradient(backgroundGradient)) .then(modifier) ) { Column( modifier = Modifier .weight(1f) .verticalScroll(state = rememberScrollState()) .widthIn(min = 0.dp, max = 500.dp) .padding(16.dp) ) { Spacer(Modifier.weight(1f)) HeaderSection() Spacer(modifier = Modifier.height(32.dp)) PreferenceCard( title = { Text(stringResource(R.string.onboarding_setup_updates)) }, description = { Text(stringResource(R.string.onboarding_setup_updates_description)) if (showNotificationPermissionNotice) { Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Outlined.Warning, contentDescription = null, tint = MaterialTheme.colorScheme.error ) Spacer(Modifier.width(8.dp)) Text( text = stringResource(R.string.onboarding_setup_notification_permission_notice), color = MaterialTheme.colorScheme.error ) } } }, action = { Switch( checked = checkForUpdatesPreference, onCheckedChange = onCheckForUpdatesPreferenceChanged ) }, onClick = { onCheckForUpdatesPreferenceChanged(!checkForUpdatesPreference) } ) if (!hideTitleLanguagePreference) { Spacer(Modifier.height(12.dp)) PreferenceCard( title = { Text(stringResource(R.string.onboarding_setup_title_language)) }, description = { Text(stringResource(R.string.onboarding_setup_title_language_description)) if (selectedTitleLanguageIndex in titleLanguages.indices) { Text( stringResource( R.string.onboarding_setup_title_language_selected, titleLanguages[selectedTitleLanguageIndex] ) ) } }, onClick = { openSelectTitleLanguageDialog = true } ) } Spacer(Modifier.weight(1f)) Spacer(Modifier.height(32.dp)) Button( onClick = onFinishClicked, modifier = Modifier.fillMaxWidth() ) { Text(text = stringResource(R.string.onboarding_setup_action)) } Spacer(modifier = Modifier.height(8.dp)) } OnboardingNavigationControls( hideNextButton = true, onBackClicked = onBackClicked, modifier = Modifier .padding(horizontal = 16.dp) .padding(bottom = 16.dp) ) } } @Composable private fun HeaderSection(modifier: Modifier = Modifier) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { Text( text = stringResource(R.string.onboarding_setup_title), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.onboarding_setup_subtitle), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center ) } } @Composable private fun SelectTitleLanguageDialog( titleLanguages: List, selectedIndex: Int, onTitleLanguageSelected: (Int) -> Unit, onDismiss: () -> Unit ) { var tmpSelectedOption by remember { mutableIntStateOf(selectedIndex) } CustomDialog( title = { Text(stringResource(R.string.onboarding_setup_title_language)) }, onDismissRequest = onDismiss, confirmButton = { TextButton(onClick = { onTitleLanguageSelected(tmpSelectedOption) }) { Text(stringResource(R.string.action_ok)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } } ) { contentPadding -> Column(modifier = Modifier.selectableGroup()) { titleLanguages.forEachIndexed { index, language -> Row( Modifier .fillMaxWidth() .height(56.dp) .selectable( selected = index == tmpSelectedOption, onClick = { tmpSelectedOption = index }, role = Role.RadioButton ) .padding(contentPadding), verticalAlignment = Alignment.CenterVertically ) { RadioButton( selected = index == tmpSelectedOption, onClick = null ) Text( text = language, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 16.dp) ) } } } } } @Preview(showBackground = true) @Composable private fun SetupPagePreview() { KitsuneTheme { SetupPage() } } @Preview(showBackground = true) @Composable private fun SelectTitleLanguageDialogPreview() { KitsuneTheme { SelectTitleLanguageDialog( titleLanguages = listOf("Canonical", "Romaji", "English"), selectedIndex = 2, onTitleLanguageSelected = {}, onDismiss = {} ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/onboarding/pages/WelcomePage.kt ================================================ package io.github.drumber.kitsune.ui.onboarding.pages import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.unit.dp import androidx.window.core.layout.WindowHeightSizeClass import androidx.window.core.layout.WindowSizeClass import androidx.window.core.layout.WindowWidthSizeClass import io.github.drumber.kitsune.R import io.github.drumber.kitsune.ui.onboarding.OnboardingUiState import io.github.drumber.kitsune.ui.onboarding.components.ImageSlideshow import io.github.drumber.kitsune.ui.onboarding.components.RandomImagePresenter import io.github.drumber.kitsune.ui.theme.KitsuneTheme @Composable fun WelcomePage( modifier: Modifier = Modifier, windowSizeClass: WindowSizeClass = currentWindowAdaptiveInfo().windowSizeClass, uiState: OnboardingUiState, onNextClicked: () -> Unit = {} ) { val overlayGradient = listOf( MaterialTheme.colorScheme.surfaceDim.copy(alpha = 0.75f), MaterialTheme.colorScheme.surface ) val imagePresenter = remember(uiState.backgroundImages) { RandomImagePresenter(uiState.backgroundImages.shuffled()) } val isVerticalLayout = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT Box(modifier = Modifier.fillMaxSize()) { AnimatedVisibility( visible = imagePresenter.hasNextImage(), enter = fadeIn(tween(durationMillis = 800)), exit = fadeOut() ) { ImageSlideshow(modifier = Modifier.fillMaxSize(), imagePresenter = imagePresenter) } Column( verticalArrangement = if (isVerticalLayout) Arrangement.Bottom else Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .fillMaxSize() .background(Brush.verticalGradient(overlayGradient)) .verticalScroll(state = rememberScrollState()) .then(modifier) .padding(16.dp) ) { if (isVerticalLayout) { HeaderSection(isCompact = windowSizeClass.windowHeightSizeClass == WindowHeightSizeClass.COMPACT) Spacer(modifier = Modifier.height(32.dp)) FeatureSection(modifier = Modifier.fillMaxWidth()) Spacer(modifier = Modifier.height(32.dp)) GetStartedButton(modifier = Modifier.fillMaxWidth(), onClick = onNextClicked) Spacer(modifier = Modifier.height(32.dp)) } else { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly, modifier = Modifier.fillMaxWidth() ) { HeaderSection( isCompact = windowSizeClass.windowHeightSizeClass != WindowHeightSizeClass.EXPANDED, modifier = Modifier .fillMaxWidth(0.5f) .fillMaxHeight() ) Spacer(modifier = Modifier.width(32.dp)) Column(modifier = Modifier.width(IntrinsicSize.Min)) { FeatureSection(modifier = Modifier.width(IntrinsicSize.Max)) Spacer(modifier = Modifier.height(32.dp)) GetStartedButton( modifier = Modifier.fillMaxWidth(), onClick = onNextClicked ) } } } } } } @Composable private fun HeaderSection(modifier: Modifier = Modifier, isCompact: Boolean = false) { val maxLogoHeight = if (isCompact) 100.dp else 180.dp Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier.wrapContentHeight() ) { Image( painter = painterResource(id = R.drawable.ic_launcher_foreground), contentDescription = null, modifier = Modifier .sizeIn( maxWidth = maxLogoHeight, maxHeight = maxLogoHeight, minWidth = 50.dp, minHeight = 50.dp ) .aspectRatio(1f) ) Spacer( modifier = if (isCompact) { Modifier.height(0.dp) } else { Modifier.height(2.dp) } ) Text( text = stringResource(R.string.onboarding_welcome_title), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(16.dp)) Text( text = stringResource(R.string.onboarding_welcome_subtitle), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) } } @Composable private fun FeatureSection(modifier: Modifier = Modifier) { Column(modifier = modifier) { FeatureItem( icon = painterResource(R.drawable.ic_search_24), title = stringResource(R.string.onboarding_welcome_feature_search), description = stringResource(R.string.onboarding_welcome_feature_search_description) ) FeatureItem( icon = painterResource(R.drawable.ic_outline_explore_24), title = stringResource(R.string.onboarding_welcome_feature_explore), description = stringResource(R.string.onboarding_welcome_feature_explore_description) ) FeatureItem( icon = painterResource(R.drawable.ic_outline_bookmarks_24), title = stringResource(R.string.onboarding_welcome_feature_track), description = stringResource(R.string.onboarding_welcome_feature_track_description) ) } } @Composable private fun FeatureItem(icon: Painter, title: String, description: String) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 8.dp) ) { Icon( painter = icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(40.dp) ) Spacer(modifier = Modifier.width(16.dp)) Column { Text( text = title, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(2.dp)) Text( text = description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) ) } } } @Composable private fun GetStartedButton(modifier: Modifier = Modifier, onClick: () -> Unit = {}) { Button( modifier = modifier, onClick = onClick ) { Text(text = stringResource(R.string.onboarding_welcome_action)) } } @PreviewScreenSizes @Preview(showBackground = true, heightDp = 780, widthDp = 390) @Composable private fun WelcomePagePreview() { KitsuneTheme { WelcomePage(uiState = OnboardingUiState()) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/permissions/NotificationPermission.kt ================================================ package io.github.drumber.kitsune.ui.permissions import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.pm.PackageManager import android.os.Build import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.github.drumber.kitsune.R fun Context.isNotificationPermissionGranted(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ActivityCompat.checkSelfPermission( this, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED } else { // no need to request permission on android versions below 33 true } } @SuppressLint("InlinedApi") fun Activity.requestNotificationPermission( requestPermissionLauncher: ActivityResultLauncher, onRationaleDialogDismiss: (() -> Unit)? = null ) { if (isNotificationPermissionGranted()) return if ( ActivityCompat.shouldShowRequestPermissionRationale( this, Manifest.permission.POST_NOTIFICATIONS ) ) { MaterialAlertDialogBuilder(this) .setTitle(R.string.dialog_request_notification_permission_title) .setMessage(R.string.dialog_request_notification_permission) .setNegativeButton(R.string.action_cancel) { dialog, _ -> dialog.dismiss() onRationaleDialogDismiss?.invoke() } .setPositiveButton(R.string.action_allow) { dialog, _ -> dialog.dismiss() requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } .show() } else { requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } fun Context.showNotificationPermissionRejectedDialog(): AlertDialog = MaterialAlertDialogBuilder(this) .setTitle(R.string.dialog_notification_permission_rejected_title) .setMessage(R.string.dialog_notification_permission_rejected) .setPositiveButton(R.string.action_ok) { dialog, _ -> dialog.dismiss() } .show() ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/photoview/PhotoViewActivity.kt ================================================ package io.github.drumber.kitsune.ui.photoview import android.Manifest import android.annotation.SuppressLint import android.content.Intent import android.content.pm.PackageManager import android.graphics.Color import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.View import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.navigation.navArgs import app.futured.hauler.setOnDragActivityListener import app.futured.hauler.setOnDragDismissedListener import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import com.google.android.material.transition.platform.MaterialContainerTransform import com.google.android.material.transition.platform.MaterialContainerTransformSharedElementCallback import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.ActivityPhotoViewBinding import io.github.drumber.kitsune.ui.base.BaseActivity import io.github.drumber.kitsune.util.extensions.clearLightNavigationBar import io.github.drumber.kitsune.util.extensions.clearLightStatusBar import io.github.drumber.kitsune.util.extensions.isNightMode import io.github.drumber.kitsune.util.extensions.showSomethingWrongToast import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.saveImageInGallery import io.github.drumber.kitsune.util.ui.initHeightWindowInsetsListener import io.github.drumber.kitsune.util.ui.initMarginWindowInsetsListener import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.IOException class PhotoViewActivity : BaseActivity(setAppTheme = false) { private lateinit var binding: ActivityPhotoViewBinding private val args by navArgs() private var isFullscreen: Boolean = false private val hideHandler = Handler(Looper.getMainLooper()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ViewCompat.setTransitionName(findViewById(android.R.id.content), args.transitionName) setEnterSharedElementCallback(MaterialContainerTransformSharedElementCallback()) window.sharedElementEnterTransition = MaterialContainerTransform().apply { addTarget(android.R.id.content) duration = resources.getInteger(R.integer.material_motion_duration_medium_2).toLong() } window.sharedElementReturnTransition = MaterialContainerTransform().apply { addTarget(android.R.id.content) duration = resources.getInteger(R.integer.material_motion_duration_medium_1).toLong() } binding = ActivityPhotoViewBinding.inflate(layoutInflater) setContentView(binding.root) if (!isNightMode()) { clearLightStatusBar() clearLightNavigationBar() } WindowInsetsControllerCompat(window, binding.root).systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE binding.photoView.apply { setOnClickListener { toggleSystemUi() } setAllowParentInterceptOnEdge(false) // we manage scroll interception on our own setOnMatrixChangeListener { // disallow touch event interception unless image is scrolled to the top binding.nestedScrollView.requestDisallowInterceptTouchEvent(it.top < 0f) } } Glide.with(this) .load(args.imageUrl) .thumbnail( Glide.with(this) .load(args.thumbnailUrl) .dontTransform() ) .listener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { binding.progressIndicator.hide() val shouldHaveThumbnailLoaded = !isFirstResource && !args.thumbnailUrl.isNullOrBlank() onImageLoadFailed(!shouldHaveThumbnailLoaded) return false } override fun onResourceReady( resource: Drawable, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { binding.progressIndicator.hide() return false } }) .dontTransform() .into(binding.photoView) binding.apply { btnClose.resetAutoHideOnTouch() btnSave.resetAutoHideOnTouch() btnOpen.resetAutoHideOnTouch() btnClose.setOnClickListener { finish() } btnSave.setOnClickListener { saveImage() } btnOpen.setOnClickListener { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(args.imageUrl)) startActivity(intent) } statusBarBackground.initHeightWindowInsetsListener(consume = false) progressIndicator.initMarginWindowInsetsListener(top = true, consume = false) fullscreenContentControls.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) } binding.haulerView.setOnDragDismissedListener { finish() } binding.haulerView.setOnDragActivityListener { _, rawOffset -> // fade background alpha on drag val alpha = (1.0f - rawOffset) * 255.0f val color = Color.argb(alpha.toInt(), 0, 0, 0) binding.root.setBackgroundColor(color) } // reset background color binding.root.setBackgroundColor(Color.BLACK) } private fun onImageLoadFailed(exit: Boolean) { Toast.makeText(this, R.string.error_image_loading, Toast.LENGTH_SHORT).show() if (exit) { finish() } } override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) hideHandler.removeCallbacks(hideRunnable) hideHandler.postDelayed(hideRunnable, UI_ANIMATION_DELAY) } private val hideRunnable = Runnable { hideSystemUi() } /** * Resets any hide callback to avoid hiding controls while the user is interacting. */ private fun resetAutoHideTime() { if (!isFullscreen) { hideHandler.removeCallbacks(hideRunnable) hideHandler.postDelayed(hideRunnable, AUTO_HIDE_DELAY_MILLIS) } } @SuppressLint("ClickableViewAccessibility") private fun View.resetAutoHideOnTouch() { setOnTouchListener { _, _ -> resetAutoHideTime() false } } private fun hideSystemUi() { isFullscreen = true WindowInsetsControllerCompat(window, binding.root) .hide(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) binding.fullscreenContentControls.isVisible = false binding.statusBarBackground.isVisible = false } private fun showSystemUi() { isFullscreen = false WindowInsetsControllerCompat(window, binding.root) .show(WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()) binding.fullscreenContentControls.isVisible = true binding.statusBarBackground.isVisible = true } private fun toggleSystemUi() { if (isFullscreen) { hideHandler.removeCallbacks(hideRunnable) if (AUTO_HIDE) { hideHandler.postDelayed(hideRunnable, AUTO_HIDE_DELAY_MILLIS) } showSystemUi() } else { hideSystemUi() } } override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, grantResults: IntArray ) { super.onRequestPermissionsResult(requestCode, permissions, grantResults) if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST_CODE && grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED ) { saveImage() } else if (requestCode == WRITE_EXTERNAL_STORAGE_REQUEST_CODE) { Toast.makeText( this, R.string.error_requires_external_storage_permission, Toast.LENGTH_LONG ).show() } } private fun saveImage() { // check if permission is granted if (shouldRequestWritePermission()) { ActivityCompat.requestPermissions( this, arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), WRITE_EXTERNAL_STORAGE_REQUEST_CODE ) return } lifecycleScope.launch(Dispatchers.IO) { val bitmap = try { Glide.with(this@PhotoViewActivity) .asBitmap() .load(args.imageUrl) .submit() .get() } catch (e: Exception) { runOnUiThread { onImageLoadFailed(false) } return@launch } val success = try { saveImageInGallery(bitmap, args.title) } catch (e: IOException) { logE("Failed to save image in gallery.", e) false } withContext(Dispatchers.Main) { if (success) { Toast.makeText( this@PhotoViewActivity, R.string.info_image_saved_in_gallery, Toast.LENGTH_SHORT ).show() } else { showSomethingWrongToast() } } } } /** * Check if external storage write permission must be requested. * On Android 10+ this is no longer required, see * [here](https://developer.android.com/training/data-storage/shared/media#scoped_storage_enabled). */ private fun shouldRequestWritePermission(): Boolean { return Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && ContextCompat.checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE ) != PackageManager.PERMISSION_GRANTED } companion object { /** * Whether or not the system UI should be auto-hidden after * [AUTO_HIDE_DELAY_MILLIS] milliseconds. */ private const val AUTO_HIDE = true /** * If [AUTO_HIDE] is set, the number of milliseconds after * hiding the system UI. */ private const val AUTO_HIDE_DELAY_MILLIS = 5000L /** * Hide system UI after this amount of milliseconds. */ private const val UI_ANIMATION_DELAY = 500L private const val WRITE_EXTERNAL_STORAGE_REQUEST_CODE = 1 } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/ProfileFragment.kt ================================================ package io.github.drumber.kitsune.ui.profile import android.content.Intent import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.View.OnClickListener import android.view.ViewGroup import android.webkit.URLUtil import android.widget.ImageView import androidx.annotation.StringRes import androidx.core.view.children import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition import com.github.mikephil.charting.data.PieDataSet import com.github.mikephil.charting.data.PieEntry import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.elevation.SurfaceColors import com.google.android.material.navigation.NavigationBarView import com.google.android.material.tabs.TabLayoutMediator import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.constants.MediaItemSize import io.github.drumber.kitsune.data.presentation.dto.toCharacterDto import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.presentation.model.media.Anime import io.github.drumber.kitsune.data.presentation.model.media.Manga import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.presentation.model.user.Favorite import io.github.drumber.kitsune.data.presentation.model.user.User import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLink import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStats import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStatsData import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStatsKind import io.github.drumber.kitsune.databinding.FragmentProfileBinding import io.github.drumber.kitsune.databinding.ItemProfileSiteChipBinding import io.github.drumber.kitsune.ui.adapter.CharacterAdapter import io.github.drumber.kitsune.ui.adapter.MediaRecyclerViewAdapter import io.github.drumber.kitsune.ui.authentication.AuthenticationActivity import io.github.drumber.kitsune.ui.base.BaseFragment import io.github.drumber.kitsune.ui.component.chart.PieChartStyle import io.github.drumber.kitsune.ui.webview.WebViewFragmentDirections import io.github.drumber.kitsune.util.extensions.copyToClipboard import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.extensions.openPhotoViewActivity import io.github.drumber.kitsune.util.extensions.openUrl import io.github.drumber.kitsune.util.extensions.recyclerView import io.github.drumber.kitsune.util.extensions.setAppTheme import io.github.drumber.kitsune.util.extensions.showSomethingWrongToast import io.github.drumber.kitsune.util.extensions.startUrlShareIntent import io.github.drumber.kitsune.util.extensions.toPx import io.github.drumber.kitsune.util.ui.getProfileSiteLogoResourceId import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.round class ProfileFragment : BaseFragment(R.layout.fragment_profile, true), NavigationBarView.OnItemReselectedListener { private var _binding: FragmentProfileBinding? = null private val binding get() = _binding!! private val viewModel: ProfileViewModel by viewModel() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentProfileBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() view.doOnPreDraw { startPostponedEnterTransition() } initToolbar() updateOptionsMenu() viewLifecycleOwner.lifecycleScope.launch { viewModel.userModel.collectLatest { user -> updateUser(user) updateProfileLinks(user?.profileLinks ?: emptyList()) updateOptionsMenu() binding.swipeRefreshLayout.isEnabled = user != null } } viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collectLatest { state -> binding.swipeRefreshLayout.apply { isRefreshing = isRefreshing && state.isRefreshing } } } binding.apply { btnLogin.setOnClickListener { val intent = Intent(requireActivity(), AuthenticationActivity::class.java) startActivity(intent) } swipeRefreshLayout.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) nsvContent.initPaddingWindowInsetsListener(bottom = true, consume = false) swipeRefreshLayout.apply { setAppTheme() setOnRefreshListener { viewModel.refreshUser() } } ivCover.setOnClickListener { val coverImgUrl = viewModel.getUser()?.coverImage?.originalOrDown() ?: return@setOnClickListener val title = viewModel.getUser()?.name?.let { "$it Cover" } openPhotoViewActivity(coverImgUrl, title, null, ivCover) } val onWaifuClicked: OnClickListener = object : OnClickListener { override fun onClick(v: View?) { val waifu = viewModel.getUser()?.waifu ?: return openCharacterDetailsBottomSheet(waifu) } } layoutWaifuRow.root.setOnClickListener(onWaifuClicked) layoutWaifuRow.tvValue.setOnClickListener(onWaifuClicked) btnFollowing.setOnClickListener { openProfilePageInWebView("following") } btnFollowers.setOnClickListener { openProfilePageInWebView("followers") } } initStatsViewPager() viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { val savedState = findNavController().currentBackStackEntry?.savedStateHandle savedState?.getStateFlow("refreshFavorites", false) ?.collectLatest { shouldRefresh -> if (shouldRefresh) { viewModel.refreshUser() savedState["refreshFavorites"] = false } } } } } private fun initToolbar() { binding.apply { collapsingToolbar.initWindowInsetsListener(consume = false) toolbar.initWindowInsetsListener(consume = false) toolbar.setOnMenuItemClickListener { item -> when (item.itemId) { R.id.menu_settings -> { val action = ProfileFragmentDirections .actionProfileFragmentToSettingsNavGraph() findNavController().navigate(action) } R.id.menu_edit_profile -> { val action = ProfileFragmentDirections .actionProfileFragmentToEditProfileFragment() findNavController().navigateSafe(R.id.profile_fragment, action) } R.id.menu_share_profile_url -> { val user = viewModel.getUser() val profileId = user?.slug ?: user?.id if (profileId != null) { val url = Kitsu.USER_URL_PREFIX + profileId startUrlShareIntent(url) } else { showSomethingWrongToast() } } R.id.menu_log_out -> { showLogOutConfirmationDialog() } } true } } } private fun setToolbarLogoClickListener() { binding.toolbar.children.firstOrNull { it is ImageView }?.setOnClickListener { logoView -> val avatarImgUrl = viewModel.getUser()?.avatar?.originalOrDown() ?: return@setOnClickListener val title = viewModel.getUser()?.name?.let { "$it Avatar" } openPhotoViewActivity(avatarImgUrl, title, null, logoView) } } private fun updateUser(user: User?) { binding.user = user binding.invalidateAll() val glide = Glide.with(this) glide.load(user?.avatar?.originalOrDown()) .dontAnimate() .circleCrop() .override(45.toPx()) .placeholder(R.drawable.profile_picture_placeholder) .into(object : CustomTarget() { override fun onResourceReady( resource: Drawable, transition: Transition? ) { binding.toolbar.logo = resource setToolbarLogoClickListener() } override fun onLoadCleared(placeholder: Drawable?) {} }) glide.load(user?.coverImage?.originalOrDown()) .centerCrop() .placeholder(ColorDrawable(SurfaceColors.SURFACE_0.getColor(requireContext()))) .into(binding.ivCover) user?.waifu?.let { waifu -> glide.asBitmap() .load(waifu.image?.originalOrDown()) .circleCrop() .dontAnimate() .into(object : CustomTarget() { override fun onResourceReady( resource: Bitmap, transition: Transition? ) { binding.layoutWaifuRow.icon = BitmapDrawable(resources, resource) } override fun onLoadCleared(placeholder: Drawable?) {} }) } user?.favorites?.let { updateFavoritesData(it) } } private fun initStatsViewPager() { val dataSet = listOf( ProfileStatsAdapter.ProfileStatsData(getString(R.string.profile_anime_stats)), ProfileStatsAdapter.ProfileStatsData(getString(R.string.profile_manga_stats)) ) val adapter = ProfileStatsAdapter(dataSet) binding.viewPagerStats.apply { this.adapter = adapter recyclerView.isNestedScrollingEnabled = false } TabLayoutMediator(binding.tabLayoutStats, binding.viewPagerStats) { tab, position -> when (position) { ProfileStatsAdapter.POS_ANIME -> tab.setText(R.string.profile_anime_stats) ProfileStatsAdapter.POS_MANGA -> tab.setText(R.string.profile_manga_stats) } }.attach() viewLifecycleOwner.lifecycleScope.launch { viewModel.userModel.collectLatest { user -> val animeCategoryStats: UserStatsData.CategoryBreakdownData? = user?.stats .findStatsData(UserStatsKind.AnimeCategoryBreakdown) updateStatsChart( ProfileStatsAdapter.POS_ANIME, R.string.profile_anime_stats, animeCategoryStats ) val mangaCategoryStats: UserStatsData.CategoryBreakdownData? = user?.stats .findStatsData(UserStatsKind.MangaCategoryBreakdown) updateStatsChart( ProfileStatsAdapter.POS_MANGA, R.string.profile_manga_stats, mangaCategoryStats ) val animeAmountConsumed: UserStatsData.AmountConsumedData? = user?.stats .findStatsData(UserStatsKind.AnimeAmountConsumed) adapter.updateAmountConsumedData(ProfileStatsAdapter.POS_ANIME, animeAmountConsumed) val mangaAmountConsumed: UserStatsData.AmountConsumedData? = user?.stats .findStatsData(UserStatsKind.MangaAmountConsumed) adapter.updateAmountConsumedData(ProfileStatsAdapter.POS_MANGA, mangaAmountConsumed) } } viewLifecycleOwner.lifecycleScope.launch { viewModel.uiState.collectLatest { state -> adapter.setLoading(ProfileStatsAdapter.POS_ANIME, state.isInitialLoading) adapter.setLoading(ProfileStatsAdapter.POS_MANGA, state.isInitialLoading) } } } private inline fun List?.findStatsData(kind: UserStatsKind): T? { return this?.find { it.kind == kind }?.statsData as? T } private fun updateStatsChart( position: Int, @StringRes titleRes: Int, categoryStats: UserStatsData.CategoryBreakdownData? ) { val categoryEntries: List = categoryStats?.let { stats -> val total = stats.total ?: return@let null val categories = stats.categories ?: return@let null categories.toList() .filter { it.second != 0 } .sortedByDescending { it.second } .take(PieChartStyle.STATS_MAX_ELEMENTS) .map { (category, value) -> PieEntry( round(value.toFloat() / total * 100f), category ) } } ?: emptyList() val set = PieDataSet(categoryEntries, getString(titleRes)) val adapter = binding.viewPagerStats.adapter as ProfileStatsAdapter adapter.updateCategoryData(position, set) } private fun updateProfileLinks(profileLinks: List) { binding.scrollViewProfileLinks.isVisible = profileLinks.isNotEmpty() binding.chipGroupProfileLinks.apply { removeAllViews() profileLinks.sortedBy { it.profileLinkSite?.id?.toIntOrNull() } .forEach { profileLink -> val profileLinkBinding = ItemProfileSiteChipBinding.inflate( layoutInflater, this, true ) val chip = profileLinkBinding.root val siteName = profileLink.profileLinkSite?.name chip.text = siteName chip.setChipIconResource(getProfileSiteLogoResourceId(siteName)) chip.setOnClickListener { onProfileLinkClicked(profileLink) } } } } private fun updateFavoritesData(favorites: List) { val favAnime = favorites.filter { it.item is Anime }.map { it.item as Anime } val favManga = favorites.filter { it.item is Manga }.map { it.item as Manga } val favCharacters = favorites.filter { it.item is Character }.map { it.item as Character } showFavoriteMediaInRecyclerView(binding.rvFavoriteAnime, favAnime) showFavoriteMediaInRecyclerView(binding.rvFavoriteManga, favManga) showFavoriteCharactersInRecyclerView(binding.rvFavoriteCharacters, favCharacters) binding.layoutFavoriteAnime.isVisible = favAnime.isNotEmpty() binding.layoutFavoriteManga.isVisible = favManga.isNotEmpty() binding.layoutFavoriteCharacters.isVisible = favCharacters.isNotEmpty() } private fun showFavoriteMediaInRecyclerView( recyclerView: RecyclerView, data: List ) { if (recyclerView.adapter !is MediaRecyclerViewAdapter) { val glide = Glide.with(this) val adapter = MediaRecyclerViewAdapter( CopyOnWriteArrayList(data), glide, itemSize = MediaItemSize.SMALL ) { view, media -> onFavoriteMediaItemClicked(view, media) } recyclerView.adapter = adapter } else { val adapter = recyclerView.adapter as MediaRecyclerViewAdapter adapter.dataSet.clear() adapter.dataSet.addAll(data) adapter.notifyDataSetChanged() } } private fun showFavoriteCharactersInRecyclerView( recyclerView: RecyclerView, data: List ) { if (recyclerView.adapter !is CharacterAdapter) { val glide = Glide.with(this) val adapter = CharacterAdapter( CopyOnWriteArrayList(data), glide, ) { _, character -> openCharacterDetailsBottomSheet(character) } recyclerView.adapter = adapter } else { val adapter = recyclerView.adapter as CharacterAdapter adapter.dataSet.clear() adapter.dataSet.addAll(data) adapter.notifyDataSetChanged() } } private fun onFavoriteMediaItemClicked(view: View, media: Media) { val action = ProfileFragmentDirections.actionProfileFragmentToDetailsFragment(media.toMediaDto()) val detailsTransitionName = getString(R.string.details_poster_transition_name) val extras = FragmentNavigatorExtras(view to detailsTransitionName) findNavController().navigateSafe(R.id.profile_fragment, action, extras) } private fun openCharacterDetailsBottomSheet(character: Character) { val action = ProfileFragmentDirections.actionProfileFragmentToCharacterDetailsBottomSheet( character.toCharacterDto() ) findNavController().navigateSafe(R.id.profile_fragment, action) } private fun updateOptionsMenu() { val isLoggedIn = viewModel.getUser() != null binding.toolbar.menu.apply { findItem(R.id.menu_edit_profile).isVisible = isLoggedIn findItem(R.id.menu_log_out).isVisible = isLoggedIn findItem(R.id.menu_share_profile_url).isVisible = isLoggedIn } } private fun showLogOutConfirmationDialog() { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.action_log_out) .setMessage(R.string.dialog_log_out_confirmation) .setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } .setPositiveButton(R.string.action_log_out) { dialog, _ -> onLogOut() dialog.dismiss() } .show() } private fun onLogOut() { viewLifecycleOwner.lifecycleScope.launch { viewModel.logOut() } } private fun onProfileLinkClicked(profileLink: ProfileLink) { profileLink.url?.let { url -> if (URLUtil.isValidUrl(url)) { openUrl(url) } else { copyToClipboard(profileLink.profileLinkSite?.name ?: "URL", url) } } } private fun openProfilePageInWebView(subPage: String) { val user = viewModel.getUser() ?: return val url = "https://kitsu.app/users/${user.slug ?: user.id}/$subPage" val webViewAction = WebViewFragmentDirections.actionGlobalWebViewFragment(url) findNavController().navigateSafe(R.id.profile_fragment, webViewAction) } override fun onNavigationItemReselected(item: MenuItem) { binding.nsvContent.smoothScrollTo(0, 0) binding.appBarLayout.setExpanded(true) } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/ProfileStatsAdapter.kt ================================================ package io.github.drumber.kitsune.ui.profile import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.text.HtmlCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.github.mikephil.charting.data.PieData import com.github.mikephil.charting.data.PieDataSet import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.user.stats.UserStatsData import io.github.drumber.kitsune.databinding.ItemProfileStatsBinding import io.github.drumber.kitsune.ui.component.chart.PieChartStyle.applyStyle import io.github.drumber.kitsune.util.TimeUtil import kotlin.math.roundToInt class ProfileStatsAdapter(dataSet: List) : RecyclerView.Adapter() { companion object { const val POS_ANIME = 0 const val POS_MANGA = 1 } private val dataSet = dataSet.toMutableList() fun updateCategoryData(position: Int, data: PieDataSet) { if (dataSet[position].categoriesDataSet == data) return dataSet[position].categoriesDataSet = data notifyItemChanged(position) } fun updateAmountConsumedData(position: Int, data: UserStatsData.AmountConsumedData?) { if (dataSet[position].amountConsumedData == data) return dataSet[position].amountConsumedData = data notifyItemChanged(position) } fun setLoading(position: Int, isLoading: Boolean) { if (dataSet[position].isLoading == isLoading) return dataSet[position].isLoading = isLoading notifyItemChanged(position) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ProfileStatsViewHolder { val binding = ItemProfileStatsBinding .inflate(LayoutInflater.from(parent.context), parent, false) return ProfileStatsViewHolder(binding) } override fun onBindViewHolder(holder: ProfileStatsViewHolder, position: Int) { holder.bind(dataSet[position]) } override fun getItemCount() = dataSet.size inner class ProfileStatsViewHolder(private val binding: ItemProfileStatsBinding) : RecyclerView.ViewHolder(binding.root) { init { binding.apply { pieChart.applyStyle(binding.root.context) progressBar.isVisible = true } } private val isAnime get() = bindingAdapterPosition == POS_ANIME private val isManga get() = bindingAdapterPosition == POS_MANGA fun bind(dataModel: ProfileStatsData) { val context = binding.root.context updateCategoryChart(dataModel) binding.apply { progressBar.isVisible = dataModel.isLoading dataModel.amountConsumedData?.let { stats -> if (isAnime) { stats.time?.let { time -> tvTimeSpent.text = context.getString( R.string.profile_stats_anime_watch_time, TimeUtil.roundTime(time, context, 1) ) if (time > 0) { tvTimeSpentTotal.text = context.getString( R.string.profile_stats_time_spent_total, TimeUtil.timeToHumanReadableFormat(time, context) ) } } } else if (isManga) { tvTimeSpent.text = stats.units?.let { chapters -> context.getString(R.string.profile_stats_manga_chapters_read, chapters) } } val completed = stats.completed val percentiles = if (isAnime) { stats.percentiles?.time } else { stats.percentiles?.units } val htmlText = if (completed != null && percentiles != null) { context.getString( R.string.profile_stats_completed, completed, percentiles.times(100).roundToInt().coerceIn(0..99) ) } else { null } tvCompleted.text = htmlText?.let { HtmlCompat.fromHtml(it, HtmlCompat.FROM_HTML_MODE_LEGACY) } } } } private fun updateCategoryChart(dataModel: ProfileStatsData) { val context = binding.root.context val isDataEmpty = dataModel.categoriesDataSet.let { it == null || it.values.isEmpty() } binding.apply { pieChart.isVisible = !isDataEmpty && !dataModel.isLoading tvCategoriesNoData.isVisible = isDataEmpty && !dataModel.isLoading } dataModel.categoriesDataSet?.let { set -> set.applyStyle(context) val pieData = PieData(set) pieData.applyStyle(context) binding.pieChart.apply { data = pieData centerText = dataModel.title invalidate() } } ?: binding.pieChart.clear() } } data class ProfileStatsData( val title: String, var categoriesDataSet: PieDataSet? = null, var amountConsumedData: UserStatsData.AmountConsumedData? = null, var isLoading: Boolean = true ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/ProfileViewModel.kt ================================================ package io.github.drumber.kitsune.ui.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.constants.Defaults import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.mapper.UserMapper.toUser import io.github.drumber.kitsune.data.presentation.model.user.User import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.domain.auth.LogOutUserUseCase import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class ProfileViewModel( private val userRepository: UserRepository, private val logOutUser: LogOutUserUseCase ) : ViewModel() { private val _refreshTrigger = MutableSharedFlow() private val _userModel = combine( userRepository.localUser, _refreshTrigger.onStart { emit(Unit) } ) { user, _ -> try { user?.let { fetchFullUserModel(it.id) ?: user.toUser() } } finally { _uiState.update { it.copy( isInitialLoading = false, isRefreshing = false ) } } }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 60000L), initialValue = userRepository.localUser.value?.toUser() ) val userModel = _userModel private val _uiState = MutableStateFlow(ProfileUiState()) val uiState = _uiState.asStateFlow() fun getUser(): User? { return _userModel.replayCache.firstOrNull() ?: userRepository.localUser.value?.toUser() } fun refreshUser() { viewModelScope.launch { _uiState.update { it.copy(isRefreshing = true) } _refreshTrigger.emit(Unit) } } private suspend fun fetchFullUserModel(userId: String): User? { return try { userRepository.fetchUser(userId, FULL_USER_FILTER) ?: throw NoDataException("Received data is null.") } catch (e: Exception) { logE("Failed to fetch full user model.", e) null } } suspend fun logOut() { viewModelScope.async { logOutUser() }.await() } companion object { val FULL_USER_FILTER get() = Filter() .include("stats", "favorites.item", "waifu", "profileLinks.profileLinkSite") .fields("media", *Defaults.MINIMUM_COLLECTION_FIELDS) .fields("characters", *Defaults.MINIMUM_CHARACTER_FIELDS) } } data class ProfileUiState( val isRefreshing: Boolean = false, val isInitialLoading: Boolean = true ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/editprofile/CharacterSearchResultAdapter.kt ================================================ package io.github.drumber.kitsune.ui.profile.editprofile import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import com.algolia.instantsearch.core.hits.HitsView import com.bumptech.glide.Glide import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.character.CharacterSearchResult import io.github.drumber.kitsune.databinding.ItemCharacterSearchResultBinding import io.github.drumber.kitsune.util.fixImageUrl class CharacterSearchResultAdapter(private val onCharacterClicked: (CharacterSearchResult) -> Unit) : RecyclerView.Adapter(), HitsView { private val characters = mutableListOf() override fun onCreateViewHolder( parent: ViewGroup, viewType: Int ): CharacterSearchResultViewHolder { return CharacterSearchResultViewHolder( ItemCharacterSearchResultBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindViewHolder(holder: CharacterSearchResultViewHolder, position: Int) { holder.bind(characters[position]) } override fun getItemCount(): Int = characters.size override fun setHits(hits: List) { characters.clear() characters.addAll(hits) notifyDataSetChanged() } inner class CharacterSearchResultViewHolder(private val binding: ItemCharacterSearchResultBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(character: CharacterSearchResult) { binding.apply { tvName.text = character.name tvMedia.text = character.primaryMediaTitle tvMedia.isVisible = !character.primaryMediaTitle.isNullOrBlank() Glide.with(root) .load(character.image?.originalOrDown()?.fixImageUrl()) .placeholder(R.drawable.character_placeholder) .into(ivCharacter) root.setOnClickListener { onCharacterClicked(character) } } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/editprofile/EditProfileFragment.kt ================================================ package io.github.drumber.kitsune.ui.profile.editprofile import android.net.Uri import android.os.Bundle import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.Toast import androidx.activity.ComponentDialog import androidx.activity.addCallback import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.core.view.postDelayed import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.algolia.instantsearch.android.searchbox.SearchBoxViewEditText import com.algolia.instantsearch.core.connection.ConnectionHandler import com.algolia.instantsearch.core.hits.connectHitsView import com.algolia.instantsearch.searchbox.connectView import com.algolia.search.helper.deserialize import com.bumptech.glide.Glide import com.google.android.material.datepicker.MaterialDatePicker import com.google.android.material.search.SearchView import com.google.android.material.textfield.MaterialAutoCompleteTextView import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.mapper.AlgoliaMapper.toCharacterSearchResult import io.github.drumber.kitsune.data.mapper.CharacterMapper.toCharacter import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLinkSite import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaCharacterSearchResult import io.github.drumber.kitsune.databinding.FragmentEditProfileBinding import io.github.drumber.kitsune.databinding.ItemProfileSiteChipBinding import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.base.BaseDialogFragment import io.github.drumber.kitsune.util.DataUtil import io.github.drumber.kitsune.util.formatDate import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.parseDate import io.github.drumber.kitsune.util.toDate import io.github.drumber.kitsune.util.ui.getProfileSiteLogoResourceId import io.github.drumber.kitsune.util.ui.initImePaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initMarginWindowInsetsListener import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class EditProfileFragment : BaseDialogFragment(R.layout.fragment_edit_profile) { private var _binding: FragmentEditProfileBinding? = null private val binding get() = _binding!! private val viewModel: EditProfileViewModel by viewModel() private val connectionHandler = ConnectionHandler() private lateinit var pickImage: ActivityResultLauncher private lateinit var legacyGetContent: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pickImage = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> onImageUriSelected(uri) } legacyGetContent = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> onImageUriSelected(uri) } } override fun onStart() { requireDialog().window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) super.onStart() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentEditProfileBinding.inflate(inflater, container, false) binding.toolbar.initWindowInsetsListener(consume = false) binding.nestedScrollView.initMarginWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) if (!viewModel.hasUser()) { Toast.makeText(requireContext(), R.string.error_invalid_user, Toast.LENGTH_LONG).show() dismiss() } binding.root.initImePaddingWindowInsetsListener() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) childFragmentManager.setFragmentResultListener( SelectProfileLinkSiteBottomSheet.PROFILE_SITE_SELECTED_REQUEST_KEY, this ) { _, bundle -> val linkSite = BundleCompat.getParcelable( bundle, SelectProfileLinkSiteBottomSheet.BUNDLE_PROFILE_LINK_SITE, ProfileLinkSite::class.java ) ?: return@setFragmentResultListener openEditProfileLinkBottomSheet(ProfileLinkEntry(null, "", linkSite), true) } childFragmentManager.setFragmentResultListener( EditProfileLinkBottomSheet.PROFILE_SUCCESS_REQUEST_KEY, this ) { _, bundle -> val profileLinkEntry = BundleCompat.getParcelable( bundle, EditProfileLinkBottomSheet.BUNDLE_PROFILE_LINK_ENTRY, ProfileLinkEntry::class.java ) ?: return@setFragmentResultListener viewModel.acceptProfileLinkAction(ProfileLinkAction.Edit(profileLinkEntry)) } childFragmentManager.setFragmentResultListener( EditProfileLinkBottomSheet.PROFILE_DELETE_REQUEST_KEY, this ) { _, bundle -> val profileLinkEntry = BundleCompat.getParcelable( bundle, EditProfileLinkBottomSheet.BUNDLE_PROFILE_LINK_ENTRY, ProfileLinkEntry::class.java ) ?: return@setFragmentResultListener viewModel.acceptProfileLinkAction(ProfileLinkAction.Delete(profileLinkEntry)) } binding.apply { toolbar.setNavigationOnClickListener { dismiss() } cardAvatar.setOnClickListener { openImagePicker(ImagePickerType.AVATAR) } cardCover.setOnClickListener { openImagePicker(ImagePickerType.COVER) } chipAddProfileLink.setOnClickListener { openSelectProfileLinkSiteBottomSheet() } fieldLocation.editText?.apply { setText(viewModel.profileState.location) doAfterTextChanged { viewModel.acceptProfileChanges( viewModel.profileState.copy( location = it?.trim().toString() ) ) } } fieldBirthday.editText?.setOnClickListener { val selectedDate = viewModel.profileState.birthday.parseDate()?.time ?: MaterialDatePicker.todayInUtcMilliseconds() openDatePicker(selectedDate, getString(R.string.profile_data_birthday)) { date -> val dateString = date.toDate().formatDate("yyyy-MM-dd") viewModel.acceptProfileChanges( viewModel.profileState.copy(birthday = dateString) ) } } fieldBirthday.setEndIconOnClickListener { if (viewModel.profileState.birthday.isEmpty()) { fieldBirthday.editText?.performClick() } else { viewModel.acceptProfileChanges( viewModel.profileState.copy(birthday = "") ) } } (menuGender.editText as? MaterialAutoCompleteTextView)?.apply { val genderItems = arrayOf( R.string.profile_data_private, R.string.profile_gender_male, R.string.profile_gender_female, R.string.profile_gender_custom ).map { getString(it) }.toTypedArray() setSimpleItems(genderItems) setText( DataUtil.getGenderString(viewModel.profileState.gender, requireContext()), false ) setOnItemClickListener { _, _, position, _ -> val gender = when (position) { 0 -> "secret" 1 -> "male" 2 -> "female" else -> "custom" } val customGender = if (gender != "custom") "" else viewModel.profileState.customGender viewModel.acceptProfileChanges( viewModel.profileState.copy( gender = gender, customGender = customGender ) ) binding.fieldCustomGender.editText?.setText(customGender) if (gender == "custom") { postDelayed(100) { binding.fieldCustomGender.editText?.requestFocus() } } } } fieldCustomGender.editText?.setText(viewModel.profileState.customGender) fieldCustomGender.editText?.doAfterTextChanged { viewModel.acceptProfileChanges( viewModel.profileState.copy(customGender = it?.trim().toString()) ) } (menuWaifu.editText as? MaterialAutoCompleteTextView)?.apply { val waifuItems = arrayOf( "", getString(R.string.profile_data_waifu), getString(R.string.profile_data_husbando) ) setSimpleItems(waifuItems) setText(viewModel.profileState.waifuOrHusbando, false) setOnItemClickListener { _, _, position, _ -> val waifuOrHusbando = waifuItems[position] viewModel.acceptProfileChanges( viewModel.profileState.copy(waifuOrHusbando = waifuOrHusbando) ) } } fieldSearchWaifu.editText?.setOnClickListener { viewModel.initSearchClient() characterSearchView.clearText() characterSearchView.show() } fieldSearchWaifu.setEndIconOnClickListener { if (viewModel.profileState.character != null) { viewModel.acceptProfileChanges( viewModel.profileState.copy(character = null) ) } else { fieldSearchWaifu.editText?.performClick() } } fieldBio.editText?.apply { setText(viewModel.profileState.about) doAfterTextChanged { viewModel.acceptProfileChanges( viewModel.profileState.copy(about = it?.trim().toString()) ) } } btnUpdateProfile.setOnClickListener { viewModel.updateUserProfile(createUserImageUpload()) } } viewLifecycleOwner.lifecycleScope.launch { viewModel.profileStateFlow.collectLatest { profileState -> binding.apply { fieldBirthday.editText?.setText( profileState.birthday.parseDate()?.formatDate() ) fieldBirthday.setEndIconDrawable( if (profileState.birthday.isEmpty()) { R.drawable.ic_calendar_month_24 } else { R.drawable.ic_close_24 } ) fieldCustomGender.isVisible = profileState.gender == "custom" fieldSearchWaifu.apply { isVisible = profileState.waifuOrHusbando.isNotBlank() editText?.setText(profileState.character?.name) setEndIconDrawable( if (profileState.character == null) { R.drawable.ic_search_24 } else { R.drawable.ic_heart_broken_24 } ) } } } } viewLifecycleOwner.lifecycleScope.launch { viewModel.profileImageStateFlow.collectLatest { profileImageState -> val avatarImage = profileImageState.selectedAvatarUri ?: profileImageState.currentAvatarUrl Glide.with(this@EditProfileFragment) .load(avatarImage) .placeholder(R.drawable.profile_picture_placeholder) .into(binding.ivAvatar) val coverImage = profileImageState.selectedCoverUri ?: profileImageState.currentCoverUrl Glide.with(this@EditProfileFragment) .load(coverImage) .placeholder(R.drawable.cover_placeholder) .into(binding.ivCover) binding.ivCoverAddImage.isVisible = coverImage == null } } viewLifecycleOwner.lifecycleScope.launch { viewModel.profileLinkEntriesFlow.collectLatest { profileLinks -> val indexOfAddChip = binding.chipGroupProfileLinks .indexOfChild(binding.chipAddProfileLink) if (indexOfAddChip != -1) { binding.chipGroupProfileLinks.removeViews(0, indexOfAddChip) } profileLinks.sortedByDescending { it.site.id?.toIntOrNull() }.forEach { link -> val chipBinding = ItemProfileSiteChipBinding.inflate( layoutInflater, binding.chipGroupProfileLinks, false ) val chip = chipBinding.root chip.text = link.site.name chip.setChipIconResource(getProfileSiteLogoResourceId(link.site.name)) chip.setOnClickListener { openEditProfileLinkBottomSheet(link, false) } binding.chipGroupProfileLinks.addView(chip, 0) } } } viewLifecycleOwner.lifecycleScope.launch { viewModel.canUpdateProfileFlow.collectLatest { canUpdate -> binding.btnUpdateProfile.isEnabled = canUpdate } } viewLifecycleOwner.lifecycleScope.launch { viewModel.loadingStateFlow.collectLatest { loadingState -> binding.layoutLoading.isVisible = loadingState is LoadingState.Loading if (loadingState is LoadingState.Error && !loadingState.isConsumed) { loadingState.isConsumed = true if (loadingState.exception is ProfileUpdateException) { showErrorToUser(loadingState.exception) } else { Toast.makeText( requireContext(), R.string.error_user_update_failed, Toast.LENGTH_LONG ).show() } } else if (loadingState is LoadingState.Success) { dismiss() } } } initSearchView() } private fun initSearchView() { val adapter = CharacterSearchResultAdapter { viewModel.acceptProfileChanges(viewModel.profileState.copy(character = it.toCharacter())) binding.characterSearchView.hide() } binding.rvCharacterResults.apply { initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) this.adapter = adapter layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false) } adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onChanged() { binding.rvCharacterResults.scrollToPosition(0) } }) binding.characterSearchView.editText.doAfterTextChanged { text -> if (text.isNullOrBlank()) { adapter.setHits(emptyList()) } } val backPressedCallback = (dialog as ComponentDialog).onBackPressedDispatcher .addCallback(this, false) { binding.characterSearchView.hide() isEnabled = false } binding.characterSearchView.addTransitionListener { _, _, newState -> backPressedCallback.isEnabled = newState == SearchView.TransitionState.SHOWN || newState == SearchView.TransitionState.SHOWING } viewLifecycleOwner.lifecycleScope.launch { viewModel.searchBoxConnectorFlow.collectLatest { searchBox -> connectionHandler.clear() val searchBoxView = SearchBoxViewEditText(binding.characterSearchView.editText) connectionHandler += searchBox.connectView(searchBoxView) connectionHandler += searchBox.searcher.connectHitsView(adapter) { response -> response.hits.deserialize(AlgoliaCharacterSearchResult.serializer()).map { it.toCharacterSearchResult() } } } } } private fun openSelectProfileLinkSiteBottomSheet() { val bottomSheet = SelectProfileLinkSiteBottomSheet() bottomSheet.show(childFragmentManager, SelectProfileLinkSiteBottomSheet.TAG) } private fun openEditProfileLinkBottomSheet( profileLinkEntry: ProfileLinkEntry, isCreatingNew: Boolean ) { val bottomSheet = EditProfileLinkBottomSheet().apply { arguments = bundleOf( EditProfileLinkBottomSheet.BUNDLE_IS_CREATING_NEW to isCreatingNew, EditProfileLinkBottomSheet.BUNDLE_PROFILE_LINK_ENTRY to profileLinkEntry ) } bottomSheet.show(childFragmentManager, EditProfileLinkBottomSheet.TAG) } private fun openDatePicker(selectedDate: Long, title: String, action: (Long) -> Unit) { val datePicker = MaterialDatePicker.Builder.datePicker() .setTitleText(title) .setSelection(selectedDate) .build() datePicker.addOnPositiveButtonClickListener(action) datePicker.show(parentFragmentManager, "DATE_PICKER") } private fun openImagePicker(type: ImagePickerType) { viewModel.currentImagePickerType = type if (!KitsunePref.forceLegacyImagePicker && ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable(requireContext()) ) { pickImage.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) } else { legacyGetContent.launch("image/*") } } private fun onImageUriSelected(uri: Uri?) { if (uri != null) { val imageState = viewModel.profileImageState val newImageState = when (viewModel.currentImagePickerType) { ImagePickerType.AVATAR -> imageState.copy(selectedAvatarUri = uri) ImagePickerType.COVER -> imageState.copy(selectedCoverUri = uri) else -> imageState } viewModel.acceptProfileImageChanges(newImageState) } viewModel.currentImagePickerType = null } private fun createUserImageUpload(): ProfileImageContainer? { val imageState = viewModel.profileImageState val avatarUri = imageState.selectedAvatarUri val coverUri = imageState.selectedCoverUri if (avatarUri == null && coverUri == null) { return null } val profileImages = ProfileImageContainer( avatar = avatarUri?.let { getBase64ImageFrom(it) }, coverImage = coverUri?.let { getBase64ImageFrom(it) } ) if (profileImages.avatar == null && profileImages.coverImage == null) { return null } return profileImages } private fun getBase64ImageFrom(uri: Uri): String? { val inputStream = requireContext().contentResolver.openInputStream(uri) ?: return null // get mime type from image (default to jpeg) val mimeType = requireContext().contentResolver.getType(uri) ?: "image/jpeg" return try { inputStream.use { stream -> val bytes = stream.readBytes() Base64.encodeToString(bytes, Base64.DEFAULT) }.let { base64 -> "data:$mimeType;base64,$base64" } } catch (e: Exception) { logE("Error while encoding image to Base64 from uri: $uri", e) null } } private fun showErrorToUser(profileUpdateException: ProfileUpdateException) { val message = when (profileUpdateException) { is ProfileUpdateException.ProfileDataError -> when (profileUpdateException.type) { ProfileDataErrorType.UpdateProfile -> getString(R.string.error_user_update_failed) ProfileDataErrorType.DeleteWaifu -> getString(R.string.error_user_delete_waifu_failed) } is ProfileUpdateException.ProfileImageError -> getString(R.string.error_user_update_image_failed) is ProfileUpdateException.ProfileLinkError -> { val siteName = profileUpdateException.profileLinkEntry.site.name ?: viewModel.profileLinkSites ?.find { it.id == profileUpdateException.profileLinkEntry.site.id }?.name ?: profileUpdateException.profileLinkEntry.url when (profileUpdateException.operation) { ProfileLinkOperation.Create -> getString( R.string.error_user_create_profile_link_failed, siteName ) ProfileLinkOperation.Update -> getString( R.string.error_user_update_profile_link_failed, siteName ) ProfileLinkOperation.Delete -> getString( R.string.error_user_delete_profile_link_failed, siteName ) } } } Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() } override fun onDestroyView() { connectionHandler.clear() super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/editprofile/EditProfileLinkBottomSheet.kt ================================================ package io.github.drumber.kitsune.ui.profile.editprofile import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.core.widget.doOnTextChanged import androidx.fragment.app.setFragmentResult import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.github.drumber.kitsune.databinding.SheetEditProfileLinkBinding import io.github.drumber.kitsune.util.ui.getProfileSiteLogoResourceId class EditProfileLinkBottomSheet : BottomSheetDialogFragment() { private var _binding: SheetEditProfileLinkBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = SheetEditProfileLinkBinding.inflate(inflater, container, false) binding.isCreatingNew = isCreatingNew() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val profileLinkEntry = arguments?.let { bundle -> BundleCompat.getParcelable( bundle, BUNDLE_PROFILE_LINK_ENTRY, ProfileLinkEntry::class.java ) } ?: return fun isConfirmButtonEnabled(): Boolean { val text = binding.fieldUrl.editText?.text?.toString() return !text.isNullOrBlank() && text != profileLinkEntry.url } binding.apply { profileLinkEntry.site.name?.let { siteName -> ivLogo.setImageResource(getProfileSiteLogoResourceId(siteName)) tvSiteName.text = siteName } fieldUrl.editText?.setText(profileLinkEntry.url) fieldUrl.editText?.doOnTextChanged { _, _, _, _ -> btnConfirm.isEnabled = isConfirmButtonEnabled() } btnDelete.setOnClickListener { setFragmentResult( PROFILE_DELETE_REQUEST_KEY, bundleOf(BUNDLE_PROFILE_LINK_ENTRY to profileLinkEntry) ) dismiss() } btnConfirm.isEnabled = isConfirmButtonEnabled() btnCancel.setOnClickListener { dismiss() } btnConfirm.setOnClickListener { val text = fieldUrl.editText?.text?.toString() if (text.isNullOrBlank()) return@setOnClickListener val editedProfileLinkEntry = profileLinkEntry.copy(url = text) setFragmentResult( PROFILE_SUCCESS_REQUEST_KEY, bundleOf(BUNDLE_PROFILE_LINK_ENTRY to editedProfileLinkEntry) ) dismiss() } } } private fun isCreatingNew() = arguments?.getBoolean(BUNDLE_IS_CREATING_NEW) == true override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val TAG = "edit_profile_link_bottom_sheet" const val BUNDLE_IS_CREATING_NEW = "is_creating_new_bundle_key" const val BUNDLE_PROFILE_LINK_ENTRY = "profile_link_entry_bundle_key" const val PROFILE_SUCCESS_REQUEST_KEY = "edit_profile_link_success_request_key" const val PROFILE_DELETE_REQUEST_KEY = "edit_profile_link_delete_request_key" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/editprofile/EditProfileViewModel.kt ================================================ package io.github.drumber.kitsune.ui.profile.editprofile import android.net.Uri import android.os.Parcelable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.algolia.instantsearch.core.connection.ConnectionHandler import com.algolia.instantsearch.searchbox.SearchBoxConnector import com.algolia.search.dsl.attributesToHighlight import com.algolia.search.dsl.attributesToRetrieve import com.algolia.search.dsl.query import com.algolia.search.dsl.responseFields import com.algolia.search.model.response.ResponseSearch import com.algolia.search.model.search.Query import com.algolia.search.model.search.RemoveStopWords import com.algolia.search.model.search.RemoveWordIfNoResults import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.mapper.CharacterMapper.toCharacter import io.github.drumber.kitsune.data.presentation.model.algolia.SearchType import io.github.drumber.kitsune.data.presentation.model.character.Character import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLink import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLinkSite import io.github.drumber.kitsune.data.repository.AlgoliaKeyRepository import io.github.drumber.kitsune.data.repository.ProfileLinkRepository import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.data.source.local.character.LocalCharacter import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.domain.algolia.SearchProvider import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize class EditProfileViewModel( private val userRepository: UserRepository, private val profileLinkRepository: ProfileLinkRepository, algoliaKeyRepository: AlgoliaKeyRepository ) : ViewModel() { val loadingStateFlow: StateFlow val profileStateFlow: StateFlow val profileImageStateFlow: StateFlow val profileLinkEntriesFlow: StateFlow> val canUpdateProfileFlow: Flow private val initialProfileLinksFlow = MutableSharedFlow>(1) private val _profileLinkSitesFlow = MutableSharedFlow>(1) private val _profileLinkSitesLoadStateFlow = MutableStateFlow(false) val profileLinkSitesFlow get() = _profileLinkSitesFlow.asSharedFlow() val profileLinkSitesLoadStateFlow: StateFlow get() = _profileLinkSitesLoadStateFlow.asStateFlow() val profileState get() = profileStateFlow.value val profileImageState get() = profileImageStateFlow.value val profileLinkEntries get() = profileLinkEntriesFlow.value val profileLinkSites get() = _profileLinkSitesFlow.replayCache.firstOrNull() val acceptProfileChanges: (ProfileState) -> Unit val acceptProfileImageChanges: (ProfileImageState) -> Unit val acceptProfileLinkAction: (ProfileLinkAction) -> Unit private val acceptLoadingState: (LoadingState) -> Unit private val searchProvider: SearchProvider private val connectionHandler = ConnectionHandler() private val _searchBoxConnectorFlow = MutableSharedFlow>(1) val searchBoxConnectorFlow get() = _searchBoxConnectorFlow.asSharedFlow() var currentImagePickerType: ImagePickerType? = null init { val user = userRepository.localUser.value val initialProfileState = ProfileState( location = user?.location ?: "", birthday = user?.birthday ?: "", gender = user?.getGenderWithoutCustomGender() ?: "", customGender = user?.getCustomGenderOrNull() ?: "", waifuOrHusbando = user?.waifuOrHusbando ?: "", character = user?.waifu?.toCharacter(), about = user?.about ?: "" ) val initialProfileImageState = ProfileImageState( currentAvatarUrl = user?.avatar?.originalOrDown(), currentCoverUrl = user?.coverImage?.originalOrDown() ) val _profileStateFlow = MutableSharedFlow() profileStateFlow = _profileStateFlow .distinctUntilChanged() .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = initialProfileState ) val _profileImageStateFlow = MutableSharedFlow() profileImageStateFlow = _profileImageStateFlow .distinctUntilChanged() .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = initialProfileImageState ) val _profileLinkEntriesStateFlow = MutableSharedFlow>() profileLinkEntriesFlow = _profileLinkEntriesStateFlow .distinctUntilChanged() .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = emptyList() ) acceptProfileChanges = { changes -> viewModelScope.launch { _profileStateFlow.emit(changes) } } acceptProfileImageChanges = { changes -> viewModelScope.launch { _profileImageStateFlow.emit(changes) } } acceptProfileLinkAction = { action -> val updatedProfileLinkEntries = when (action) { is ProfileLinkAction.Edit -> { val initialEntry = initialProfileLinksFlow.replayCache.firstOrNull() ?.firstOrNull { it.site.id == action.profileLinkEntry.site.id } val entry = initialEntry?.copy(url = action.profileLinkEntry.url) ?: action.profileLinkEntry profileLinkEntries.filter { it.site != entry.site } + entry } is ProfileLinkAction.Delete -> profileLinkEntries.filter { it.site != action.profileLinkEntry.site } } viewModelScope.launch { _profileLinkEntriesStateFlow.emit(updatedProfileLinkEntries) } } canUpdateProfileFlow = combine( profileStateFlow, profileImageStateFlow, profileLinkEntriesFlow, initialProfileLinksFlow ) { profileState, profileImageState, profileLinkEntries, initialProfileLinkEntries -> profileState != initialProfileState || profileImageState != initialProfileImageState || profileLinkEntries.toSet() != initialProfileLinkEntries.toSet() } val _loadingStateFlow = MutableSharedFlow() loadingStateFlow = _loadingStateFlow .distinctUntilChanged() .stateIn( scope = viewModelScope, started = SharingStarted.Lazily, initialValue = LoadingState.NotLoading ) acceptLoadingState = { loadingState -> viewModelScope.launch { _loadingStateFlow.emit(loadingState) } } searchProvider = SearchProvider(algoliaKeyRepository) user?.id?.let { initProfileLinks(it) } viewModelScope.launch { val initialProfileLinks = initialProfileLinksFlow.first() _profileLinkEntriesStateFlow.emit(initialProfileLinks + profileLinkEntries) } } fun hasUser() = userRepository.hasLocalUser() fun updateUserProfile(profileImages: ProfileImageContainer?) { val user = userRepository.localUser.value ?: return val changes = profileState val waifu = if (changes.character != null && changes.waifuOrHusbando.isNotBlank()) { LocalCharacter.empty(changes.character.id) } else { null } val updatedUserModel = LocalUser.empty(user.id).copy( location = changes.location, birthday = changes.birthday, gender = if (changes.gender == "custom") changes.customGender else changes.gender, waifuOrHusbando = changes.waifuOrHusbando, about = changes.about, waifu = waifu ) acceptLoadingState(LoadingState.Loading) viewModelScope.launch(Dispatchers.IO) { try { if (user.waifu != null && waifu == null) { deleteWaifuRelationship(user.id) } if (profileImages != null) { uploadUserImages(user.id, profileImages) } updateProfileLinks(user.id) val response = userRepository.updateUser(user.id, updatedUserModel) if (response != null) { // request full user model to update local cached model userRepository.fetchAndStoreLocalUserFromNetwork() acceptLoadingState(LoadingState.Success) } else { throw ProfileUpdateException.ProfileDataError(ProfileDataErrorType.UpdateProfile) } } catch (e: Exception) { logE("Failed to update user profile.", e) acceptLoadingState(LoadingState.Error(e)) } } } private suspend fun deleteWaifuRelationship(userId: String) { logD("Deleting waifu relationship.") val isSuccessful = userRepository.deleteWaifuRelationship(userId) if (!isSuccessful) { throw ProfileUpdateException.ProfileDataError(ProfileDataErrorType.DeleteWaifu) } } private suspend fun uploadUserImages(useId: String, profileImages: ProfileImageContainer) { logD("Updating user image(s).") val isSuccessful = userRepository.updateUserImage(useId, profileImages.avatar, profileImages.coverImage) if (!isSuccessful) { throw ProfileUpdateException.ProfileImageError() } } private suspend fun updateProfileLinks(userId: String) = withContext(Dispatchers.IO) { val initialProfileLinks = initialProfileLinksFlow.replayCache.firstOrNull() ?: emptyList() val newProfileLinks = profileLinkEntries.filter { newEntry -> initialProfileLinks.none { it.site.id == newEntry.site.id } } val updatedProfileLinks = profileLinkEntries.filter { newEntry -> newEntry.id != null && initialProfileLinks.none { it == newEntry } } val deletedProfileLinks = initialProfileLinks.filter { initialEntry -> initialEntry.id != null && profileLinkEntries.none { it.site.id == initialEntry.site.id } } newProfileLinks.forEach { profileLinkEntry -> try { val createdProfileLink = createProfileLink(profileLinkEntry, userId) ?: throw NoDataException("Received response is null.") addOrUpdateInitialProfileLink(profileLinkEntry, createdProfileLink) } catch (e: Exception) { logE("Failed to create profile link $profileLinkEntry.", e) throw ProfileUpdateException.ProfileLinkError( ProfileLinkOperation.Create, profileLinkEntry ) } } updatedProfileLinks.forEach { profileLinkEntry -> try { val updatedProfileLink = updateProfileLink(profileLinkEntry, userId) ?: throw NoDataException("Received response is null.") addOrUpdateInitialProfileLink(profileLinkEntry, updatedProfileLink) } catch (e: Exception) { logE("Failed to update profile link $profileLinkEntry.", e) throw ProfileUpdateException.ProfileLinkError( ProfileLinkOperation.Update, profileLinkEntry ) } } deletedProfileLinks.forEach { profileLinkEntry -> try { val isSuccessful = profileLinkRepository.deleteProfileLink(profileLinkEntry.id!!) if (!isSuccessful) { throw NoDataException("Failed to delete profile link.") } removeFromInitialProfileLinks(profileLinkEntry) } catch (e: Exception) { logE("Failed to delete profile link $profileLinkEntry.", e) throw ProfileUpdateException.ProfileLinkError( ProfileLinkOperation.Delete, profileLinkEntry ) } } } private suspend fun createProfileLink( profileLinkEntry: ProfileLinkEntry, userId: String ): ProfileLink? { return profileLinkRepository.createProfileLink( userId, profileLinkEntry.site.id, profileLinkEntry.url ) } private suspend fun updateProfileLink( profileLinkEntry: ProfileLinkEntry, userId: String ): ProfileLink? { return profileLinkRepository.updateProfileLink( userId, profileLinkEntry.id!!, profileLinkEntry.url ) } private fun addOrUpdateInitialProfileLink( localProfileLinkEntry: ProfileLinkEntry, remoteProfileLink: ProfileLink ) { val initialProfileLinkEntry = initialProfileLinksFlow.replayCache.firstOrNull() ?.find { it.id == remoteProfileLink.id || it.site.id == remoteProfileLink.profileLinkSite?.id } val updatedProfileLinkEntry = when (initialProfileLinkEntry) { // add new profile link entry to initialProfileLinks null -> localProfileLinkEntry.copy( id = remoteProfileLink.id, url = remoteProfileLink.url ?: localProfileLinkEntry.url, site = remoteProfileLink.profileLinkSite ?: localProfileLinkEntry.site ) // update initialProfileLinkEntry with remoteProfileLink else -> initialProfileLinkEntry.copy( id = remoteProfileLink.id, url = remoteProfileLink.url ?: localProfileLinkEntry.url ) } val initialProfileLinksWithoutEntry = getInitialProfileLinksWithout(remoteProfileLink) viewModelScope.launch { initialProfileLinksFlow.emit(initialProfileLinksWithoutEntry + updatedProfileLinkEntry) } } private fun removeFromInitialProfileLinks(profileLinkEntry: ProfileLinkEntry) { val initialProfileLinksWithoutEntry = getInitialProfileLinksWithout( ProfileLink( id = profileLinkEntry.id ?: "", url = profileLinkEntry.url, profileLinkSite = profileLinkEntry.site, user = null ) ) viewModelScope.launch { initialProfileLinksFlow.emit(initialProfileLinksWithoutEntry) } } private fun getInitialProfileLinksWithout(profileLink: ProfileLink): List { return initialProfileLinksFlow.replayCache.firstOrNull() ?.filterNot { it.id == profileLink.id || it.site.id == profileLink.profileLinkSite?.id } ?: emptyList() } fun initSearchClient() { if (searchProvider.isInitialized) return val characterSearchQuery = query { attributesToRetrieve { +"id" +"slug" +"canonicalName" +"image" +"primaryMedia" } responseFields { +Hits } attributesToHighlight { } removeStopWords = RemoveStopWords.False removeWordsIfNoResults = RemoveWordIfNoResults.AllOptional } createSearchClient(characterSearchQuery) } private fun createSearchClient(query: Query) { viewModelScope.launch(Dispatchers.IO) { try { searchProvider.createSearchClient( SearchType.Characters, query, triggerSearchFor = { !it.query.isNullOrBlank() } ) { searcher -> connectionHandler.clear() val searchBoxConnector = SearchBoxConnector(searcher) connectionHandler += searchBoxConnector _searchBoxConnectorFlow.emit(searchBoxConnector) } } catch (e: Exception) { logE("Failed to create search client.", e) } } } private fun initProfileLinks(userId: String) { viewModelScope.launch(Dispatchers.IO) { acceptLoadingState(LoadingState.Loading) val userProfileLinks = fetchUserProfileLinks(userId).mapNotNull { profileLink -> val url = profileLink.url ?: return@mapNotNull null val site = profileLink.profileLinkSite ?: return@mapNotNull null ProfileLinkEntry(profileLink.id, url, site) } initialProfileLinksFlow.emit(userProfileLinks) acceptLoadingState(LoadingState.NotLoading) } } private suspend fun fetchUserProfileLinks(userId: String): List { return try { val filter = Filter() .limit(50) .include("profileLinkSite") userRepository.getProfileLinksForUser(userId, filter) ?: emptyList() } catch (e: Exception) { logE("Failed to fetch profile links for user.", e) emptyList() } } fun loadProfileLinkSites() { if (!profileLinkSites.isNullOrEmpty()) return viewModelScope.launch(Dispatchers.IO) { _profileLinkSitesLoadStateFlow.emit(true) val profileLinkSites = fetchProfileLinkSites() _profileLinkSitesFlow.emit(profileLinkSites) _profileLinkSitesLoadStateFlow.emit(false) } } private suspend fun fetchProfileLinkSites(): List { return try { val filter = Filter().pageLimit(50) profileLinkRepository.getAllProfileLinkSites(filter) ?: emptyList() } catch (e: Exception) { logE("Failed to fetch profile link sites.", e) emptyList() } } private fun LocalUser.getGenderWithoutCustomGender(): String? { return when (gender) { null, "", "male", "female", "secret" -> gender else -> "custom" } } private fun LocalUser.getCustomGenderOrNull(): String? { return when (gender) { "male", "female", "secret" -> null else -> gender } } override fun onCleared() { super.onCleared() connectionHandler.clear() searchProvider.cancel() } } data class ProfileState( val location: String, val birthday: String, val gender: String, val customGender: String, val waifuOrHusbando: String, val character: Character?, val about: String ) data class ProfileImageState( val currentAvatarUrl: String?, val currentCoverUrl: String?, val selectedAvatarUri: Uri? = null, val selectedCoverUri: Uri? = null ) data class ProfileImageContainer( val avatar: String?, val coverImage: String? ) @Parcelize data class ProfileLinkEntry( val id: String? = null, val url: String, val site: ProfileLinkSite ) : Parcelable sealed class ProfileLinkAction { data class Edit(val profileLinkEntry: ProfileLinkEntry) : ProfileLinkAction() data class Delete(val profileLinkEntry: ProfileLinkEntry) : ProfileLinkAction() } sealed class LoadingState { data object NotLoading : LoadingState() data object Loading : LoadingState() data object Success : LoadingState() data class Error(val exception: Exception, var isConsumed: Boolean = false) : LoadingState() } sealed class ProfileUpdateException : Exception() { class ProfileDataError(val type: ProfileDataErrorType) : ProfileUpdateException() { override val message: String get() = "Failed to update profile data. Type: $type" } class ProfileImageError : ProfileUpdateException() class ProfileLinkError( val operation: ProfileLinkOperation, val profileLinkEntry: ProfileLinkEntry ) : ProfileUpdateException() { override val message: String get() = "Failed to $operation profile link $profileLinkEntry." } } enum class ProfileDataErrorType { UpdateProfile, DeleteWaifu } enum class ProfileLinkOperation { Create, Update, Delete } enum class ImagePickerType { AVATAR, COVER } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/profile/editprofile/SelectProfileLinkSiteBottomSheet.kt ================================================ package io.github.drumber.kitsune.ui.profile.editprofile import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.content.res.ResourcesCompat import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.setFragmentResult import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomsheet.BottomSheetDialogFragment import io.github.drumber.kitsune.data.presentation.model.user.profilelinks.ProfileLinkSite import io.github.drumber.kitsune.databinding.ItemListOptionBinding import io.github.drumber.kitsune.databinding.SheetSelectProfileLinkSiteBinding import io.github.drumber.kitsune.util.ItemClickListener import io.github.drumber.kitsune.util.ui.getProfileSiteLogoResourceId import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel class SelectProfileLinkSiteBottomSheet : BottomSheetDialogFragment() { private val viewModel: EditProfileViewModel by viewModel(ownerProducer = { requireParentFragment() }) private var _binding: SheetSelectProfileLinkSiteBinding? = null private val binding get() = _binding!! override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = SheetSelectProfileLinkSiteBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewModel.loadProfileLinkSites() viewLifecycleOwner.lifecycleScope.launch { viewModel.profileLinkSitesFlow.collectLatest { profileLinkSites -> val addedProfileLinks = viewModel.profileLinkEntries val profileLinksWithoutAddedEntries = profileLinkSites.filter { profileLinkSite -> addedProfileLinks.none { it.site.id == profileLinkSite.id } } updateProfileLinkSites(profileLinksWithoutAddedEntries) } } viewLifecycleOwner.lifecycleScope.launch { viewModel.profileLinkSitesLoadStateFlow.collectLatest { binding.progressBarProfileLinkSites.isVisible = it } } } private fun updateProfileLinkSites(linkSites: List) { binding.layoutListParent.removeAllViews() linkSites.forEach { linkSite -> if (linkSite.name.isNullOrBlank()) return@forEach val itemBinding = ItemListOptionBinding.inflate(layoutInflater, binding.layoutListParent, true) itemBinding.title = linkSite.name itemBinding.icon = ResourcesCompat.getDrawable( resources, getProfileSiteLogoResourceId(linkSite.name), activity?.theme ) itemBinding.listener = ItemClickListener { onItemClicked(linkSite) } } } private fun onItemClicked(linkSite: ProfileLinkSite) { setFragmentResult( PROFILE_SITE_SELECTED_REQUEST_KEY, bundleOf(BUNDLE_PROFILE_LINK_SITE to linkSite) ) dismiss() } override fun onDestroy() { super.onDestroy() _binding = null } companion object { const val TAG = "select_profile_link_site_bottom_sheet" const val BUNDLE_PROFILE_LINK_SITE = "profile_link_site_bundle_key" const val PROFILE_SITE_SELECTED_REQUEST_KEY = "site_selected_request_key" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/search/SearchFragment.kt ================================================ package io.github.drumber.kitsune.ui.search import android.annotation.SuppressLint import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import androidx.core.view.doOnPreDraw import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.navigation.fragment.FragmentNavigatorExtras import androidx.navigation.fragment.findNavController import androidx.paging.LoadState import com.algolia.instantsearch.android.searchbox.SearchBoxViewAppCompat import com.algolia.instantsearch.core.connection.AbstractConnection import com.algolia.instantsearch.core.connection.ConnectionHandler import com.algolia.instantsearch.searchbox.SearchBoxConnector import com.algolia.instantsearch.searchbox.connectView import com.algolia.search.model.response.ResponseSearch import com.bumptech.glide.Glide import com.google.android.material.badge.BadgeDrawable import com.google.android.material.badge.BadgeUtils import com.google.android.material.navigation.NavigationBarView import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.databinding.FragmentSearchBinding import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.adapter.OnItemClickListener import io.github.drumber.kitsune.ui.adapter.paging.MediaSearchPagingAdapter import io.github.drumber.kitsune.ui.adapter.paging.ResourceLoadStateAdapter import io.github.drumber.kitsune.ui.component.LoadStateSpanSizeLookup import io.github.drumber.kitsune.ui.component.ResponsiveGridLayoutManager import io.github.drumber.kitsune.ui.component.updateLoadState import io.github.drumber.kitsune.ui.main.FragmentDecorationPreference import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.Error import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.Initialized import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.NotAvailable import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.NotInitialized import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.activityViewModel import java.lang.ref.WeakReference class SearchFragment : Fragment(R.layout.fragment_search), FragmentDecorationPreference, OnItemClickListener, NavigationBarView.OnItemReselectedListener { override val hasTransparentStatusBar = false private var _binding: FragmentSearchBinding? = null private val binding get() = _binding!! private val viewModel: SearchViewModel by activityViewModel() private val connectionHandler = ConnectionHandler() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentSearchBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) postponeEnterTransition() if (findNavController().currentBackStackEntry?.arguments == null) { view.doOnPreDraw { startPostponedEnterTransition() } } else { // safeguard: ensure startPostponedEnterTransition() got called within 200ms view.postDelayed({ startPostponedEnterTransition() }, 200) } binding.apply { root.initPaddingWindowInsetsListener( left = true, top = true, right = true, consume = false ) rvMedia.initPaddingWindowInsetsListener(bottom = true, consume = false) } initRecyclerView() initSearchBar() observeSearchBox() observeFilters() initSearchProviderStatusLayout() } private fun initRecyclerView() { val adapter = MediaSearchPagingAdapter(Glide.with(this), this) val columnWidth = resources.getDimension(KitsunePref.mediaItemSize.widthRes) + 2 * resources.getDimension(R.dimen.media_item_margin) val gridLayout = ResponsiveGridLayoutManager(requireContext(), columnWidth.toInt(), 2) gridLayout.spanSizeLookup = LoadStateSpanSizeLookup(adapter, gridLayout) binding.rvMedia.adapter = adapter.withLoadStateHeaderAndFooter( header = ResourceLoadStateAdapter(adapter), footer = ResourceLoadStateAdapter(adapter) ) binding.rvMedia.layoutManager = gridLayout binding.rvMedia.itemAnimator = null binding.layoutLoading.btnRetry.setOnClickListener { adapter.retry() } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { adapter.loadStateFlow.collectLatest { loadState -> binding.layoutLoading.updateLoadState( binding.rvMedia, adapter.itemCount, loadState ) if (loadState.refresh is LoadState.NotLoading) { binding.rvMedia.doOnPreDraw { startPostponedEnterTransition() } } } } } viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.searchResultSource.collectLatest { adapter.submitData(it) } } } } private fun initSearchBar() { binding.btnSearch.setOnClickListener { val isSearchFocussed = binding.searchView.getTag(TAG_SEARCH_FOCUSED) as? Boolean if (isSearchFocussed == true) { val focusedView = binding.searchView.findFocus() focusedView.clearFocus() val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(focusedView.windowToken, 0) } else { focusSearchView() } } binding.searchView.setOnQueryTextFocusChangeListener { _, hasFocus -> binding.btnSearch.setImageResource( if (hasFocus) R.drawable.ic_arrow_back_24 else R.drawable.ic_search_24 ) binding.searchView.setTag(TAG_SEARCH_FOCUSED, hasFocus) } binding.btnFilter.apply { setOnClickListener { val action = SearchFragmentDirections.actionSearchFragmentToFacetFragment() findNavController().navigateSafe(R.id.search_fragment, action) } setOnLongClickListener { if (!viewModel.filtersLiveData.value?.getFilters().isNullOrEmpty()) { viewModel.clearSearchFilter() return@setOnLongClickListener true } false } } } private fun observeSearchBox() { viewModel.searchBox.observe(viewLifecycleOwner) { searchBox -> val searchBoxView = SearchBoxViewAppCompat(binding.searchView) connectionHandler += searchBox.connectView(searchBoxView) connectionHandler += SearchResponseListener(searchBox) { binding.rvMedia.post { if (!isAdded) return@post // scroll to top when searching binding.rvMedia.scrollToPosition(0) binding.appBarLayout.setExpanded(true) } } } } private fun initSearchProviderStatusLayout() { binding.layoutSearchProviderStatus.btnRetrySearchProvider.setOnClickListener { viewModel.initializeSearchClient() } viewModel.searchClientStatus.observe(viewLifecycleOwner) { status -> binding.layoutSearchProviderStatus.apply { root.isVisible = status != Initialized btnRetrySearchProvider.isVisible = status == Error || status == NotAvailable tvStatus.isVisible = btnRetrySearchProvider.isVisible progressBarSearchProvider.isVisible = status == NotInitialized } } } @SuppressLint("UnsafeOptInUsageError") private fun observeFilters() { viewModel.filtersLiveData.observe(viewLifecycleOwner) { filters -> val filterCount = filters?.getFilters()?.size ?: 0 binding.btnFilter.post { if (!isAdded) return@post binding.btnFilter.overlay.clear() val badgeDrawable = BadgeDrawable.create(binding.btnFilter.context).apply { isVisible = filterCount > 0 number = filterCount } BadgeUtils.attachBadgeDrawable(badgeDrawable, binding.btnFilter) } } } override fun onItemClick(view: View, item: Media) { val action = SearchFragmentDirections.actionSearchFragmentToDetailsFragment(item.toMediaDto()) val detailsTransitionName = getString(R.string.details_poster_transition_name) val extras = FragmentNavigatorExtras(view to detailsTransitionName) findNavController().navigateSafe(R.id.search_fragment, action, extras) } override fun onNavigationItemReselected(item: MenuItem) { binding.appBarLayout.setExpanded(true) if (binding.rvMedia.canScrollVertically(-1)) { binding.rvMedia.smoothScrollToPosition(0) } else { focusSearchView() } } private fun focusSearchView() { binding.searchView.requestFocus() val imm = requireActivity().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.showSoftInput(binding.searchView.findFocus(), InputMethodManager.SHOW_IMPLICIT) } override fun onDestroyView() { connectionHandler.clear() super.onDestroyView() _binding = null } companion object { @SuppressLint("NonConstantResourceId") const val TAG_SEARCH_FOCUSED = R.drawable.ic_search_24 } /** * Triggers the onSearchReceived callback after the * search query was changed AND the response is received. */ private class SearchResponseListener( searchBox: SearchBoxConnector, private val onSearchReceived: () -> Unit ) : AbstractConnection() { private val _searchBox = WeakReference(searchBox) private var pendingSearch = false private val onQueryChanged = { _: Any? -> pendingSearch = true } private val onSearchResponse = { r: ResponseSearch? -> // new data was received while there is a pending search, so notify the callback if (pendingSearch) { onSearchReceived() } // reset pendingSearch flag when the first page was received if (pendingSearch && r?.pageOrNull == 0) { pendingSearch = false } } override fun connect() { super.connect() _searchBox.get()?.let { it.viewModel.query.subscribe(onQueryChanged) it.searcher.response.subscribe(onSearchResponse) } } override fun disconnect() { super.disconnect() _searchBox.get()?.let { it.viewModel.query.unsubscribe(onQueryChanged) it.searcher.response.unsubscribe(onSearchResponse) } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/search/SearchViewModel.kt ================================================ package io.github.drumber.kitsune.ui.search import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope import androidx.paging.PagingConfig import androidx.paging.cachedIn import com.algolia.instantsearch.android.paging3.Paginator import com.algolia.instantsearch.android.paging3.filterstate.connectPaginator import com.algolia.instantsearch.android.paging3.flow import com.algolia.instantsearch.android.paging3.searchbox.connectPaginator import com.algolia.instantsearch.core.connection.AbstractConnection import com.algolia.instantsearch.core.connection.ConnectionHandler import com.algolia.instantsearch.core.selectable.list.SelectionMode import com.algolia.instantsearch.filter.facet.DefaultFacetListPresenter import com.algolia.instantsearch.filter.facet.FacetListConnector import com.algolia.instantsearch.filter.facet.FacetSortCriterion import com.algolia.instantsearch.filter.state.FilterState import com.algolia.instantsearch.filter.state.Filters import com.algolia.instantsearch.filter.state.groupOr import com.algolia.instantsearch.searchbox.SearchBoxConnector import com.algolia.instantsearch.searcher.connectFilterState import com.algolia.instantsearch.searcher.hits.HitsSearcher import com.algolia.search.dsl.attributesToRetrieve import com.algolia.search.dsl.query import com.algolia.search.model.Attribute import com.algolia.search.model.filter.Filter import com.algolia.search.model.response.ResponseSearch import com.algolia.search.model.search.Query import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.constants.Repository import io.github.drumber.kitsune.data.mapper.AlgoliaMapper.toMedia import io.github.drumber.kitsune.data.presentation.model.algolia.SearchType import io.github.drumber.kitsune.data.presentation.model.media.Media import io.github.drumber.kitsune.data.repository.AlgoliaKeyRepository import io.github.drumber.kitsune.data.source.network.algolia.model.search.AlgoliaMediaSearchResult import io.github.drumber.kitsune.domain.algolia.FilterCollection import io.github.drumber.kitsune.domain.algolia.SearchProvider import io.github.drumber.kitsune.domain.algolia.toCombinedMap import io.github.drumber.kitsune.domain.algolia.toFilterCollection import io.github.drumber.kitsune.data.common.exception.SearchProviderUnavailableException import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.component.algolia.SeasonListPresenter import io.github.drumber.kitsune.ui.component.algolia.range.CustomFilterRangeConnector import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.logI import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromJsonElement import java.util.Calendar class SearchViewModel( algoliaKeyRepository: AlgoliaKeyRepository ) : ViewModel() { private val searchProvider = SearchProvider(algoliaKeyRepository) private val searchSelector = MutableLiveData>() private var filterState: FilterState? = null private val searchPaginator = MutableLiveData>() private val _filtersLiveData = MutableLiveData() val filtersLiveData get() = _filtersLiveData as LiveData private val _searchClientStatus = MutableLiveData(SearchClientStatus.NotInitialized) val searchClientStatus get() = _searchClientStatus as LiveData private val _searchBox = MutableLiveData>() val searchBox get() = _searchBox as LiveData> private val _filterFacets = MutableLiveData() val filterFacets get() = _filterFacets as LiveData private val connectionHandler = ConnectionHandler() private val json = Json { ignoreUnknownKeys = true } init { initializeSearchClient() } fun initializeSearchClient() { if (searchProvider.isInitialized) return val query = query { attributesToRetrieve { +"id" +"slug" +"kind" +"canonicalTitle" +"titles" +"posterImage" +"subtype" } } createSearchClient(SearchType.Media, query) } private fun createSearchClient(searchType: SearchType, query: Query) { viewModelScope.launch { filterState = null _searchClientStatus.postValue(SearchClientStatus.NotInitialized) try { searchProvider.createSearchClient(searchType, query) { searcher -> searchPaginator.value?.invalidate() connectionHandler.clear() searchSelector.postValue(Pair(searchType, searcher)) val paginator = Paginator( searcher = searcher, pagingConfig = PagingConfig( pageSize = Kitsu.DEFAULT_PAGE_SIZE, maxSize = Repository.MAX_CACHED_ITEMS ), transformer = { hit -> when (searchType) { SearchType.Media -> json.decodeFromJsonElement(hit.json).toMedia() else -> throw IllegalStateException("Search type '$searchType' is not supported.") } } ) searchPaginator.postValue(paginator) val filterState = if (KitsunePref.rememberSearchFilters) { val storedFilters = KitsunePref.searchFilters.toCombinedMap() FilterState(storedFilters) } else { FilterState() } createFilterFacets(searcher, filterState) connectionHandler += searcher.connectFilterState(filterState) connectionHandler += filterState.connectPaginator(paginator) _filtersLiveData.postValue(filterState.filters.value) filterState.filters.subscribe { _filtersLiveData.postValue(it) // store search filters KitsunePref.searchFilters = it.toFilterCollection() } createSearchBox(searcher, paginator) this@SearchViewModel.filterState = filterState _searchClientStatus.postValue(SearchClientStatus.Initialized) } } catch (e: SearchProviderUnavailableException) { logI("Search provider not available. Is the device offline?") _searchClientStatus.postValue(SearchClientStatus.NotAvailable) } catch (e: Exception) { logE("Could not create search client.", e) _searchClientStatus.postValue(SearchClientStatus.Error) } } } private fun createSearchBox(searcher: HitsSearcher, paginator: Paginator) { val searchBox = SearchBoxConnector(searcher) connectionHandler += searchBox connectionHandler += searchBox.connectPaginator(paginator) _searchBox.postValue(searchBox) } val searchResultSource = searchPaginator.asFlow().flatMapLatest { paginator -> paginator.flow }.cachedIn(viewModelScope) private fun createFilterFacets(searcher: HitsSearcher, filterState: FilterState) { val filterFacets = FilterFacets(searcher, filterState) applyCategoryFilters(filterState) _filterFacets.postValue(filterFacets) } fun clearSearchFilter() { filterState?.notify { clear(*getGroups().keys.toTypedArray()) } KitsunePref.searchFilters = FilterCollection() KitsunePref.searchCategories = emptyList() _filtersLiveData.postValue(null) } private fun applyCategoryFilters(filterState: FilterState) { filterState.notify { val categories = Attribute("categories") val filterFacets = KitsunePref.searchCategories.mapNotNull { wrapper -> wrapper.categoryName?.let { categoryName -> Filter.Facet(categories, categoryName) } } val group = groupOr(categories) clear(group) if (filterFacets.isNotEmpty()) { add(group, *filterFacets.toTypedArray()) } } } fun updateCategoryFilters() { filterState?.let { applyCategoryFilters(it) } } override fun onCleared() { super.onCleared() searchProvider.cancel() connectionHandler.clear() } inner class FilterFacets( searcher: HitsSearcher, filterState: FilterState ) { val kindConnector = FacetListConnector( searcher = searcher, filterState = filterState, attribute = Attribute("kind"), selectionMode = SelectionMode.Multiple, ).bind() val kindPresenter = DefaultFacetListPresenter(limit = 2) val yearConnector = CustomFilterRangeConnector( filterState = filterState, attribute = Attribute("year"), range = minYear..maxYear, bounds = minYear..maxYear ).bind() val avgRatingConnector = CustomFilterRangeConnector( filterState = filterState, attribute = Attribute("averageRating"), range = 5..100, bounds = 5..100 ).bind() val seasonConnector = FacetListConnector( searcher = searcher, filterState = filterState, attribute = Attribute("season"), selectionMode = SelectionMode.Multiple, ).bind() val seasonPresenter = SeasonListPresenter() val subtypeConnector = FacetListConnector( searcher = searcher, filterState = filterState, attribute = Attribute("subtype"), selectionMode = SelectionMode.Multiple, ).bind() val subtypePresenter = DefaultFacetListPresenter(limit = 100, sortBy = defaultFacetSortBy) val streamersConnector = FacetListConnector( searcher = searcher, filterState = filterState, attribute = Attribute("streamers"), selectionMode = SelectionMode.Multiple, ).bind() val streamersPresenter = DefaultFacetListPresenter(limit = 100, sortBy = defaultFacetSortBy) val ageRatingConnector = FacetListConnector( searcher = searcher, filterState = filterState, attribute = Attribute("ageRating"), selectionMode = SelectionMode.Multiple, ).bind() val ageRatingPresenter = DefaultFacetListPresenter(limit = 4, sortBy = defaultFacetSortBy) private fun T.bind() = apply { connectionHandler += this } } companion object { private val defaultFacetSortBy get() = listOf( FacetSortCriterion.IsRefined, FacetSortCriterion.CountDescending ) val maxYear get() = Calendar.getInstance().get(Calendar.YEAR) + 2 const val minYear = 1862 } enum class SearchClientStatus { NotInitialized, Initialized, NotAvailable, Error } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/search/categories/CategoriesDialogFragment.kt ================================================ package io.github.drumber.kitsune.ui.search.categories import android.content.DialogInterface import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import com.google.android.material.snackbar.Snackbar import com.unnamed.b.atv.model.TreeNode import com.unnamed.b.atv.view.AndroidTreeView import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.presentation.model.media.category.CategoryNode import io.github.drumber.kitsune.databinding.FragmentCategoriesBinding import io.github.drumber.kitsune.preference.CategoryPrefWrapper import io.github.drumber.kitsune.ui.base.BaseDialogFragment import io.github.drumber.kitsune.util.network.ResponseData import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import org.koin.androidx.viewmodel.ext.android.viewModel class CategoriesDialogFragment : BaseDialogFragment(R.layout.fragment_categories) { private var _binding: FragmentCategoriesBinding? = null private val binding get() = _binding!! private val viewModel: CategoriesViewModel by viewModel() private var onDismissListener: DialogInterface.OnDismissListener? = null private lateinit var treeView: AndroidTreeView private lateinit var treeRoot: TreeNode override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentCategoriesBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.apply { collapsingToolbar.initWindowInsetsListener(consume = false) toolbar.initWindowInsetsListener(consume = false) toolbar.setNavigationOnClickListener { dismiss() } toolbar.inflateMenu(R.menu.category_dialog_menu) toolbar.setOnMenuItemClickListener { onMenuItemClicked(it) } nestedScrollView.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) layoutLoading.btnRetry.setOnClickListener { viewModel.fetchChildCategories(null) } } toggleLoadingLayout(true) initTreeView() } private fun initTreeView() { treeRoot = TreeNode.root() treeView = AndroidTreeView(requireContext(), treeRoot) treeView.setDefaultAnimation(true) treeView.setDefaultContainerStyle(R.style.TreeNodeStyle) treeView.isSelectionModeEnabled = true var isTreeViewDataSet = false viewModel.categoryNodes.observe(viewLifecycleOwner) { response -> toggleLoadingLayout(false) if (response !is ResponseData.Success) { displayLoadingError((response as ResponseData.Error).e) return@observe } val categories = response.data if (isTreeViewDataSet) { // restore state after fetching a new category viewModel.treeViewSavedState = treeView.saveState } treeRoot = TreeNode.root() categories .sortedBy { it.category.title } .forEach { category -> addCategoryTreeNode(treeRoot, category) } val prevScrollY = binding.nestedScrollView.scrollY treeView.setDefaultAnimation(false) treeView.setRoot(treeRoot) binding.treeViewContainer.apply { removeAllViews() addView(treeView.view) } viewModel.treeViewSavedState?.let { treeView.restoreState(it) } viewModel.selectedCategories.toSet().forEach { categoryWrapper -> selectTreeNodeForCategory(treeRoot, categoryWrapper.categoryId) } isTreeViewDataSet = true // restore scroll position binding.nestedScrollView.scrollTo(0, prevScrollY) treeView.setDefaultAnimation(true) updateSelectionCounter() } } private fun addCategoryTreeNode(parent: TreeNode, categoryNode: CategoryNode) { val node = TreeNode(categoryNode) val viewHolder = CategoryViewHolder(requireContext()) { if (it.childCategories.isEmpty()) { viewModel.fetchChildCategories(it) } } viewHolder.onSelectionChangeListener = { onNodeSelectionChange(it) } node.viewHolder = viewHolder node.isSelectable = true if (categoryNode.childCategories.isNotEmpty()) { categoryNode.childCategories .sortedBy { it.category.title } .forEach { childCategory -> addCategoryTreeNode(node, childCategory) } } parent.addChild(node) } private fun selectTreeNodeForCategory(parentNode: TreeNode, categoryId: String?): TreeNode? { val node = parentNode.children.find { childNode -> categoryId == (childNode.value as CategoryNode).category.id } return if (node != null) { treeView.selectNode(node, true) node } else { parentNode.children.forEach { val found = selectTreeNodeForCategory(it, categoryId) if (found != null) { return found } } null } } private fun onNodeSelectionChange(node: TreeNode) { val wrapper = getCategoryWrapper(node) if (node.isSelected) { viewModel.addSelectedCategory(wrapper) } else { viewModel.removeSelectedCategory(wrapper) } updateSelectionCounter() } private fun getCategoryWrapper(childNode: TreeNode): CategoryPrefWrapper { val parentCategories = findRootCategoryNodes(childNode) val parentIds = parentCategories.map { (it.value as CategoryNode).category.id } val category = (childNode.value as CategoryNode).category return CategoryPrefWrapper(category.id, category.title, category.slug, parentIds) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) viewModel.storeSelectedCategories() onDismissListener?.onDismiss(dialog) } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) viewModel.treeViewSavedState = treeView.saveState } private fun updateSelectionCounter(parentNode: TreeNode = treeRoot) { parentNode.children.forEach { child -> val categoryNode = child.value as CategoryNode if (categoryNode.hasChildren()) { categoryNode.category.id.let { id -> val selectedChildren = viewModel.countSelectedChildrenForParent(id) val viewHolder = child.viewHolder as CategoryViewHolder viewHolder.onSelectionCounterUpdate(selectedChildren) } updateSelectionCounter(child) } } } private fun findRootCategoryNodes(childNode: TreeNode, targetLevel: Int = 1): List { val parentList = mutableListOf() var node: TreeNode = childNode while (node.parent != null) { val parent = node.parent parentList.add(parent) if (parent.level == targetLevel) { break } node = parent } return parentList } private fun toggleLoadingLayout(isVisible: Boolean) { binding.layoutLoading.apply { root.isVisible = isVisible btnRetry.isVisible = false tvError.isVisible = false } } private fun displayLoadingError(e: Throwable) { val errorMsg = "Error: ${e.message}" if (treeRoot.children.isEmpty()) { binding.layoutLoading.apply { root.isVisible = true progressBar.isVisible = false tvError.isVisible = true tvError.text = errorMsg btnRetry.isVisible = true } } else { Snackbar.make(binding.root, "Error: $errorMsg", Snackbar.LENGTH_LONG).show() } } private fun onMenuItemClicked(item: MenuItem): Boolean { if (item.itemId == R.id.unselect_all) { treeView.deselectAll() viewModel.clearSelectedCategories() updateSelectionCounter() } else { return false } return true } fun setOnDismissListener(listener: DialogInterface.OnDismissListener) { onDismissListener = listener } override fun onDestroyView() { super.onDestroyView() _binding = null } companion object { private const val TAG = "categories_dialog" fun showDialog(fragmentManager: FragmentManager): CategoriesDialogFragment { val fragment = CategoriesDialogFragment() fragment.show(fragmentManager, TAG) return fragment } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/search/categories/CategoriesViewModel.kt ================================================ package io.github.drumber.kitsune.ui.search.categories import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.data.presentation.model.media.category.CategoryNode import io.github.drumber.kitsune.data.repository.CategoryRepository import io.github.drumber.kitsune.preference.CategoryPrefWrapper import io.github.drumber.kitsune.data.common.Filter import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.util.logE import io.github.drumber.kitsune.util.network.ResponseData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class CategoriesViewModel(private val categoryRepository: CategoryRepository) : ViewModel() { var treeViewSavedState: String? = null private val _selectedCategories: MutableSet = KitsunePref.searchCategories.toMutableSet() val selectedCategories: Set get() = _selectedCategories fun storeSelectedCategories() { KitsunePref.searchCategories = selectedCategories.toList() } fun addSelectedCategory(category: CategoryPrefWrapper) { _selectedCategories.add(category) } fun removeSelectedCategory(category: CategoryPrefWrapper) { _selectedCategories.remove(category) } fun clearSelectedCategories() { _selectedCategories.clear() } fun countSelectedChildrenForParent(parentId: String): Int { return selectedCategories.filter { it.parentIds?.contains(parentId) == true }.size } private val _categoryNodes = MutableLiveData>>() val categoryNodes: LiveData>> get() = _categoryNodes fun fetchChildCategories(parent: CategoryNode?) { val parentId = parent?.category?.id ?: "_none" val filter = Filter() .filter("parent_id", parentId) .pageLimit(500) viewModelScope.launch(Dispatchers.IO) { try { categoryRepository.getAllCategories(filter)?.let { categories -> val nodes = categories.map { CategoryNode(it) } if (parent == null) { _categoryNodes.postValue(ResponseData.Success(nodes)) } else { parent.childCategories.addAll(0, nodes) if (_categoryNodes.value is ResponseData.Success || _categoryNodes.value?.data == null) { _categoryNodes.postValue(_categoryNodes.value) } else { val responseData = ResponseData.Success(categoryNodes.value?.data!!) _categoryNodes.postValue(responseData) } } } } catch (e: Exception) { logE("Failed to fetch categories.", e) _categoryNodes.postValue(ResponseData.Error(e, categoryNodes.value?.data)) } } } init { fetchChildCategories(null) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/search/categories/CategoryViewHolder.kt ================================================ package io.github.drumber.kitsune.ui.search.categories import android.content.Context import android.view.LayoutInflater import android.view.View import androidx.core.view.isVisible import com.unnamed.b.atv.model.TreeNode import io.github.drumber.kitsune.data.presentation.model.media.category.CategoryNode import io.github.drumber.kitsune.databinding.ItemCategoryNodeBinding class CategoryViewHolder( context: Context, private val callback: (CategoryNode) -> Unit ) : TreeNode.BaseNodeViewHolder(context) { private lateinit var binding: ItemCategoryNodeBinding var onSelectionChangeListener: ((TreeNode) -> Unit)? = null override fun createNodeView(node: TreeNode, value: CategoryNode): View { binding = ItemCategoryNodeBinding.inflate(LayoutInflater.from(context), null, false) binding.apply { tvName.text = value.category.title ivExpand.visibility = if(value.hasChildren()) View.VISIBLE else View.INVISIBLE divider.isVisible = !node.isFirstChild checkbox.isVisible = node.level > 1 checkbox.isChecked = node.isSelected root.setOnClickListener { if(value.hasChildren()) { ivExpand.rotation = if(node.isExpanded) 180f else 0f callback(value) tView.toggleNode(node) } else { checkbox.isChecked = !checkbox.isChecked } } checkbox.setOnCheckedChangeListener { button, isChecked -> node.isSelected = isChecked onSelectionChangeListener?.invoke(node) } } return binding.root } fun onSelectionCounterUpdate(selectedChildren: Int) { if(!this::binding.isInitialized) return binding.tvCounter.apply { text = if (selectedChildren < 100) { selectedChildren.toString() } else { "99+" } isVisible = selectedChildren > 0 } } override fun toggle(active: Boolean) { binding.ivExpand.rotation = if(active) 180f else 0f } override fun toggleSelectionMode(editModeEnabled: Boolean) { binding.apply { checkbox.isChecked = mNode.isSelected checkbox.jumpDrawablesToCurrentState() // prevent checkbox animation } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/search/filter/FacetFragment.kt ================================================ package io.github.drumber.kitsune.ui.search.filter import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Button import android.widget.TextView import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.algolia.instantsearch.android.filter.facet.FacetListAdapter import com.algolia.instantsearch.android.list.autoScrollToStart import com.algolia.instantsearch.core.connection.ConnectionHandler import com.algolia.instantsearch.filter.facet.connectView import com.google.android.material.divider.MaterialDividerItemDecoration import com.google.android.material.navigation.NavigationBarView import com.google.android.material.slider.RangeSlider import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.FragmentFilterFacetBinding import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.component.ExpandableLayout import io.github.drumber.kitsune.ui.component.algolia.range.IntNumberRangeView import io.github.drumber.kitsune.ui.component.algolia.range.connectView import io.github.drumber.kitsune.ui.main.FragmentDecorationPreference import io.github.drumber.kitsune.ui.search.SearchViewModel import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.Error import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.Initialized import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.NotAvailable import io.github.drumber.kitsune.ui.search.SearchViewModel.SearchClientStatus.NotInitialized import io.github.drumber.kitsune.ui.search.categories.CategoriesDialogFragment import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import org.koin.androidx.viewmodel.ext.android.activityViewModel class FacetFragment : Fragment(R.layout.fragment_filter_facet), FragmentDecorationPreference, NavigationBarView.OnItemReselectedListener { override val hasTransparentStatusBar = true private var _binding: FragmentFilterFacetBinding? = null private val binding get() = _binding!! private val connection = ConnectionHandler() private val viewModel: SearchViewModel by activityViewModel() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentFilterFacetBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.toolbar.apply { initWindowInsetsListener(false) setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener { onMenuItemClicked(it) } } binding.nsvContent.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) viewModel.filterFacets.observe(viewLifecycleOwner) { filterFacets -> createFilterViews(filterFacets) } binding.layoutSearchProviderStatus.btnRetrySearchProvider.setOnClickListener { viewModel.initializeSearchClient() } viewModel.searchClientStatus.observe(viewLifecycleOwner) { status -> binding.apply { nsvContent.isVisible = status == Initialized layoutSearchProviderStatus.apply { root.isVisible = status != Initialized btnRetrySearchProvider.isVisible = status == Error || status == NotAvailable tvStatus.isVisible = btnRetrySearchProvider.isVisible progressBarSearchProvider.isVisible = status == NotInitialized } } } binding.toolbar.menu.findItem(R.id.menu_reset_filter).isVisible = false viewModel.filtersLiveData.observe(viewLifecycleOwner) { filters -> val filterCount = filters?.getFilters()?.size ?: 0 binding.toolbar.menu.findItem(R.id.menu_reset_filter).isVisible = filterCount > 0 updateCategoriesCounter() } initCategoriesCard() } private fun onMenuItemClicked(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.menu_reset_filter -> { viewModel.clearSearchFilter() true } else -> false } } private fun initCategoriesCard() { binding.cardCategories.setOnClickListener { showCategoriesDialog() } updateCategoriesCounter() } private fun showCategoriesDialog() { parentFragmentManager.fragments.forEach { fragment -> if (fragment is DialogFragment) { // dismiss any open dialogs fragment.dismissAllowingStateLoss() } } val dialog = CategoriesDialogFragment.showDialog(parentFragmentManager) dialog.setOnDismissListener { updateCategoriesCounter() viewModel.updateCategoryFilters() } } private fun updateCategoriesCounter() { val numCategories = KitsunePref.searchCategories.size binding.tvCategoriesCounter.apply { isVisible = numCategories > 0 text = numCategories.toString() } } private fun createFilterViews(filterFacets: SearchViewModel.FilterFacets) { val adapterKind = FacetListAdapter(FilterFacetListViewHolder.Factory) binding.rvKind.initAdapter(adapterKind, binding.tvKind) connection += filterFacets.kindConnector.connectView( adapterKind, filterFacets.kindPresenter ) val yearView = IntNumberRangeView(binding.sliderYear) binding.sliderYear.attachTextView(binding.tvYearValue) { min, max -> when (max) { SearchViewModel.maxYear -> "$min - ∞" else -> "$min - $max" } } connection += filterFacets.yearConnector.connectView(yearView) val avgRatingView = IntNumberRangeView(binding.sliderAvgRating) binding.sliderAvgRating.attachTextView(binding.tvAvgRatingValue) { min, max -> "$min% - $max%" } connection += filterFacets.avgRatingConnector.connectView(avgRatingView) val adapterSeason = FacetListAdapter(FilterFacetListViewHolder.Factory) binding.rvSeason.initAdapter(adapterSeason, binding.tvSeason) connection += filterFacets.seasonConnector.connectView( adapterSeason, filterFacets.seasonPresenter ) val adapterSubtype = FacetListAdapter(FilterFacetListViewHolder.Factory) binding.rvSubtype.initAdapter(adapterSubtype, binding.tvSubtype) binding.wrapperSubtype.connectButton(binding.btnExpandSubtype) connection += filterFacets.subtypeConnector.connectView( adapterSubtype, filterFacets.subtypePresenter ) val adapterStreamers = FacetListAdapter(FilterFacetListViewHolder.Factory) binding.rvStreamers.initAdapter(adapterStreamers, binding.tvStreamers) binding.wrapperStreamers.connectButton(binding.btnExpandStreamers) connection += filterFacets.streamersConnector.connectView( adapterStreamers, filterFacets.streamersPresenter ) val adapterAgeRating = FacetListAdapter(FilterFacetListViewHolder.Factory) binding.rvAgeRating.initAdapter(adapterAgeRating, binding.tvAgeRating) connection += filterFacets.ageRatingConnector.connectView( adapterAgeRating, filterFacets.ageRatingPresenter ) } private fun RecyclerView.initAdapter(adapter: RecyclerView.Adapter<*>, label: View? = null) { this.adapter = adapter layoutManager = LinearLayoutManager(requireContext()) val divider = MaterialDividerItemDecoration(requireContext(), LinearLayoutManager.VERTICAL) addItemDecoration(divider) autoScrollToStart(adapter) if (label != null) { adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { label.isVisible = adapter.itemCount > 0 } override fun onItemRangeRemoved(positionStart: Int, itemCount: Int) { label.isVisible = adapter.itemCount > 0 } }) } } private fun ExpandableLayout.connectButton(button: Button) { button.apply { setOnClickListener { toggle() } setText(if (isExpanded()) R.string.action_show_less else R.string.action_show_more) } expandedState.observe(viewLifecycleOwner) { expanded -> button.setText(if (expanded) R.string.action_show_less else R.string.action_show_more) } addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> button.isVisible = minHeight.toInt() <= measuredHeight } } private fun RangeSlider.attachTextView(textView: TextView, getText: (min: Int, max: Int) -> String) { val updateText = { slider: RangeSlider -> val valueMin = slider.values[0].toInt() val valueMax = slider.values[1].toInt() textView.text = getText(valueMin, valueMax) } addOnChangeListener { slider, _, _ -> updateText(slider) } if (values.size >= 2) { updateText(this) } } override fun onNavigationItemReselected(item: MenuItem) { if (binding.nsvContent.canScrollVertically(-1)) { binding.nsvContent.smoothScrollTo(0, 0) } else { findNavController().navigateUp() } } override fun onDestroyView() { connection.clear() super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/search/filter/FilterFacetListViewHolder.kt ================================================ package io.github.drumber.kitsune.ui.search.filter import android.view.View import android.view.ViewGroup import com.algolia.instantsearch.android.filter.facet.FacetListViewHolder import com.algolia.instantsearch.android.inflate import com.algolia.search.model.search.Facet import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.ItemFacetBinding class FilterFacetListViewHolder(view: View) : FacetListViewHolder(view) { override fun bind(facet: Facet, selected: Boolean, onClickListener: View.OnClickListener) { val binding = ItemFacetBinding.bind(view) view.setOnClickListener(onClickListener) binding.apply { facetCount.text = facet.count.toString() facetCount.visibility = View.VISIBLE icon.visibility = if (selected) View.VISIBLE else View.INVISIBLE facetName.text = facet.value.replaceFirstChar(Char::titlecase) } } object Factory : FacetListViewHolder.Factory { override fun createViewHolder(parent: ViewGroup): FacetListViewHolder { return FilterFacetListViewHolder(parent.inflate(R.layout.item_facet)) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/settings/AppLogsFragment.kt ================================================ package io.github.drumber.kitsune.ui.settings import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.content.FileProvider import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.color.MaterialColors import com.google.android.material.transition.MaterialSharedAxis import io.github.drumber.kitsune.BuildConfig import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.FragmentAppLogsBinding import io.github.drumber.kitsune.util.LogCatReader import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.File import java.text.SimpleDateFormat import java.util.Date class AppLogsFragment : Fragment(R.layout.fragment_app_logs) { private var _binding: FragmentAppLogsBinding? = null private val binding get() = _binding!! private val viewModel: AppLogsViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentAppLogsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val colorBackground = MaterialColors.getColor(view, android.R.attr.colorBackground) view.setBackgroundColor(colorBackground) binding.toolbar.initWindowInsetsListener(consume = false) binding.nestedScrollView.initPaddingWindowInsetsListener( left = true, right = true, bottom = true, consume = false ) binding.toolbar.setNavigationOnClickListener { findNavController().navigateUp() } binding.toolbar.setOnMenuItemClickListener { menuItem -> if (menuItem.itemId == R.id.menu_share_app_logs) { shareLogFile() } true } viewModel.logMessages.observe(viewLifecycleOwner) { logMessages -> binding.apply { progressBar.isVisible = false tvNoLogs.isVisible = logMessages.isBlank() tvLogMessages.isVisible = logMessages.isNotBlank() tvLogMessages.text = logMessages if (savedInstanceState == null) { nestedScrollView.post { if (!isAdded) return@post nestedScrollView.fullScroll(View.FOCUS_DOWN) } } } } } @SuppressLint("SimpleDateFormat") private fun shareLogFile() { val dateTime = SimpleDateFormat("yyy-MM-dd_HH-mm-ss").format(Date()) val fileName = "Kitsune_$dateTime.txt" val logsDir = File(requireContext().cacheDir, "logs") val logFile = File(logsDir, fileName) deleteAllFiles(logsDir) // delete any previously created log files logFile.deleteOnExit() lifecycleScope.launch { LogCatReader.writeAppLogsToFile(logFile) val contentUri = FileProvider.getUriForFile( requireContext(), "${BuildConfig.APPLICATION_ID}.fileprovider", logFile ) val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "text/*" putExtra(Intent.EXTRA_STREAM, contentUri) } startActivity( Intent.createChooser( shareIntent, getText(R.string.action_share_app_logs) ) ) } } private fun deleteAllFiles(directory: File) { directory.listFiles { file: File -> file.isFile }?.forEach { it.delete() } } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/settings/AppLogsViewModel.kt ================================================ package io.github.drumber.kitsune.ui.settings import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.util.LogCatReader import kotlinx.coroutines.launch class AppLogsViewModel : ViewModel() { private var _logMessages = MutableLiveData() val logMessages: LiveData get() = _logMessages init { viewModelScope.launch { val logCatMessages = LogCatReader.readAppLogs() _logMessages.value = logCatMessages.joinToString("\n") } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/settings/AppearanceFragment.kt ================================================ package io.github.drumber.kitsune.ui.settings import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatDelegate import androidx.preference.ListPreference import androidx.preference.SwitchPreferenceCompat import com.google.android.material.color.DynamicColors import com.google.android.material.color.MaterialColors import com.google.android.material.transition.MaterialSharedAxis import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.AppTheme import io.github.drumber.kitsune.constants.MediaItemSize import io.github.drumber.kitsune.domain.work.UpdateLibraryWidgetUseCase import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.base.BasePreferenceFragment import org.koin.android.ext.android.inject class AppearanceFragment : BasePreferenceFragment(R.string.nav_appearance) { private val updateLibraryWidget: UpdateLibraryWidgetUseCase by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val colorBackground = MaterialColors.getColor(view, android.R.attr.colorBackground) view.setBackgroundColor(colorBackground) } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.sharedPreferencesName = getString(R.string.preference_file_key) setPreferencesFromResource(R.xml.appearance_preferences, rootKey) //---- Dynamic Color Theme findPreference(R.string.preference_key_dynamic_color_theme)?.apply { isVisible = DynamicColors.isDynamicColorAvailable() setOnPreferenceChangeListener { _, newValue -> KitsunePref.useDynamicColorTheme = newValue as Boolean updateLibraryWidget(context) true } } //---- App Theme findPreference(R.string.preference_key_app_theme)?.apply { isEnabled = !KitsunePref.useDynamicColorTheme val themeEntries = getThemePreferenceEntries() setThemeEntries(themeEntries) setSelectedTheme(KitsunePref.appTheme.toThemeEntry()) setOnPreferenceChangeListener { _, newValue -> val themeIndex = themeEntries.indexOf(newValue as ThemePickerPreference.ThemeEntry) KitsunePref.appTheme = AppTheme.entries[themeIndex] updateLibraryWidget(context) true } } //---- Dark Mode findPreference(R.string.preference_key_dark_mode)?.apply { value = KitsunePref.darkMode setOnPreferenceChangeListener { _, newValue -> if (KitsunePref.darkMode != newValue) { KitsunePref.darkMode = newValue as String AppCompatDelegate.setDefaultNightMode(newValue.toInt()) } true } } //---- OLED Black Mode findPreference(R.string.preference_key_oled_black_mode)?.apply { isEnabled = !KitsunePref.useDynamicColorTheme setOnPreferenceChangeListener { _, newValue -> KitsunePref.oledBlackMode = newValue as Boolean true } } //---- Media Item Size findPreference(R.string.preference_key_media_item_size)?.apply { entryValues = MediaItemSize.entries.map { it.name }.toTypedArray() value = KitsunePref.mediaItemSize.name setOnPreferenceChangeListener { _, newValue -> KitsunePref.mediaItemSize = MediaItemSize.valueOf(newValue as String) true } } } private fun getThemePreferenceEntries(): List { return AppTheme.entries.map { it.toThemeEntry() } } private fun AppTheme.toThemeEntry() = when (this) { AppTheme.DEFAULT -> ThemePickerPreference.ThemeEntry( name = R.string.preference_app_theme_default, primaryColor = R.color.md_theme_primary, secondaryColor = R.color.md_theme_secondary, surfaceColor = R.color.md_theme_surface ) AppTheme.PURPLE -> ThemePickerPreference.ThemeEntry( name = R.string.preference_app_theme_purple, primaryColor = R.color.md_purple_theme_primary, secondaryColor = R.color.md_purple_theme_secondary, surfaceColor = R.color.md_purple_theme_surface ) AppTheme.BLUE -> ThemePickerPreference.ThemeEntry( name = R.string.preference_app_theme_blue, primaryColor = R.color.md_blue_theme_primary, secondaryColor = R.color.md_blue_theme_secondary, surfaceColor = R.color.md_blue_theme_surface ) AppTheme.GREEN -> ThemePickerPreference.ThemeEntry( name = R.string.preference_app_theme_green, primaryColor = R.color.md_green_theme_primary, secondaryColor = R.color.md_green_theme_secondary, surfaceColor = R.color.md_green_theme_surface ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/settings/OSLibrariesFragment.kt ================================================ package io.github.drumber.kitsune.ui.settings import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.navigation.findNavController import com.google.android.material.color.MaterialColors import com.google.android.material.transition.MaterialSharedAxis import com.mikepenz.aboutlibraries.LibsBuilder import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.FragmentOsLibrariesBinding import io.github.drumber.kitsune.util.ui.initWindowInsetsListener class OSLibrariesFragment : Fragment(R.layout.fragment_os_libraries) { private var _binding: FragmentOsLibrariesBinding? = null private val binding get() = _binding!! override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enterTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) returnTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentOsLibrariesBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val colorBackground = MaterialColors.getColor(view, android.R.attr.colorBackground) view.setBackgroundColor(colorBackground) binding.collapsingToolbar.initWindowInsetsListener(consume = false) binding.toolbar.apply { initWindowInsetsListener(consume = false) setNavigationOnClickListener { findNavController().navigateUp() } } val aboutLibrariesFragment = LibsBuilder() .withLicenseShown(true) .withEdgeToEdge(true) .withShowLoadingProgress(true) .supportFragment() childFragmentManager.beginTransaction() .replace(R.id.os_libraries_fragment_container, aboutLibrariesFragment) .commit() } override fun onDestroyView() { super.onDestroyView() _binding = null } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/settings/SettingsFragment.kt ================================================ package io.github.drumber.kitsune.ui.settings import android.os.Bundle import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatDelegate import androidx.core.os.LocaleListCompat import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.preference.EditTextPreference import androidx.preference.ListPreference import androidx.preference.ListPreference.SimpleSummaryProvider import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.google.android.material.transition.MaterialSharedAxis import io.github.drumber.kitsune.AppLocales import io.github.drumber.kitsune.BuildConfig import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.Kitsu import io.github.drumber.kitsune.data.presentation.model.appupdate.UpdateCheckResult import io.github.drumber.kitsune.data.repository.AppUpdateRepository import io.github.drumber.kitsune.data.source.local.user.model.LocalRatingSystemPreference import io.github.drumber.kitsune.data.source.local.user.model.LocalSfwFilterPreference import io.github.drumber.kitsune.data.source.local.user.model.LocalTitleLanguagePreference import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.databinding.FragmentPreferenceBinding import io.github.drumber.kitsune.notification.Notifications import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.preference.StartPagePref import io.github.drumber.kitsune.ui.base.BasePreferenceFragment import io.github.drumber.kitsune.ui.permissions.isNotificationPermissionGranted import io.github.drumber.kitsune.ui.permissions.requestNotificationPermission import io.github.drumber.kitsune.util.extensions.navigateSafe import io.github.drumber.kitsune.util.extensions.openUrl import io.github.drumber.kitsune.util.ui.initMarginWindowInsetsListener import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import java.util.Locale class SettingsFragment : BasePreferenceFragment() { private val viewModel: SettingsViewModel by viewModel() private val appUpdateRepository: AppUpdateRepository by inject() // this result listener will be called on requesting notification permission after the // 'check for updates on launch' permission was changed and notification permission is not granted private lateinit var requestNotificationPermissionLauncher: ActivityResultLauncher override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) exitTransition = MaterialSharedAxis(MaterialSharedAxis.X, true) reenterTransition = MaterialSharedAxis(MaterialSharedAxis.X, false) requestNotificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> val preference = findPreference(R.string.preference_key_check_for_updates_on_start) if (isGranted) { KitsunePref.flagUserDeniedNotificationPermission = false } else { preference?.isChecked = false KitsunePref.flagUserDeniedNotificationPermission = true Toast.makeText( requireContext(), R.string.error_requires_notification_permission, Toast.LENGTH_LONG ).show() } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { preferenceManager.sharedPreferencesName = getString(R.string.preference_file_key) setPreferencesFromResource(R.xml.app_preferences, rootKey) //---- Appearance findPreference(R.string.preference_key_fragment_appearance)?.setOnPreferenceClickListener { val action = SettingsFragmentDirections.actionSettingsFragmentToAppearanceFragment() findNavController().navigate(action) true } //---- App Language findPreference(R.string.preference_key_language)?.apply { val supportedLocales = AppLocales.SUPPORTED_LOCALES val selectedLocale = AppCompatDelegate.getApplicationLocales() .getFirstMatch(supportedLocales) val selectedLocaleValue = supportedLocales.find { Locale.forLanguageTag(it).language == selectedLocale?.language } val languageDisplayNames = supportedLocales.map { Locale.forLanguageTag(it) .getDisplayLanguage(selectedLocale ?: Locale.getDefault()) }.toTypedArray() entryValues = arrayOf("", *supportedLocales) entries = arrayOf( getString(R.string.preference_language_default), *languageDisplayNames ) value = selectedLocaleValue ?: "" setOnPreferenceChangeListener { _, newValue -> val localeList = when (newValue.toString()) { "" -> LocaleListCompat.getEmptyLocaleList() else -> LocaleListCompat.forLanguageTags(newValue.toString()) } AppCompatDelegate.setApplicationLocales(localeList) true } } //---- Start Fragment findPreference(R.string.preference_key_start_fragment)?.apply { entryValues = StartPagePref.entries.map { it.name }.toTypedArray() value = KitsunePref.startFragment.name setSummaryProvider { getString(R.string.preference_start_fragment_description, entry) } setOnPreferenceChangeListener { _, newValue -> KitsunePref.startFragment = StartPagePref.valueOf(newValue as String) true } } //---- Force legacy image picker findPreference(R.string.preference_key_force_legacy_image_picker)?.isVisible = ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable(requireContext()) //---- Check for Updates on Launch findPreference(R.string.preference_key_check_for_updates_on_start) ?.setOnPreferenceChangeListener { _, newValue -> if (newValue as Boolean && !requireContext().isNotificationPermissionGranted()) { requireActivity().requestNotificationPermission( requestNotificationPermissionLauncher ) return@setOnPreferenceChangeListener false } true } //---- App Logs findPreference(R.string.preference_key_app_logs)?.setOnPreferenceClickListener { val action = SettingsFragmentDirections.actionSettingsFragmentToAppLogsFragment() findNavController().navigateSafe(R.id.settingsFragment, action) true } //---- App Version val appVersion = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" findPreference(R.string.preference_key_app_version)?.apply { summary = appVersion + System.lineSeparator() + getString(R.string.preference_app_version_description) setOnPreferenceClickListener { checkForNewVersion() true } } //---- Open Source Libraries findPreference(R.string.preference_key_open_source_libraries)?.setOnPreferenceClickListener { val action = SettingsFragmentDirections.actionSettingsFragmentToLibrariesFragment() findNavController().navigateSafe(R.id.settingsFragment, action) true } observeUserModel() } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val colorBackground = MaterialColors.getColor(view, android.R.attr.colorBackground) view.setBackgroundColor(colorBackground) val binding = FragmentPreferenceBinding.bind(view) viewModel.errorMessageListener = { Snackbar.make(view, "Error: ${it.getMessage(requireContext())}", Snackbar.LENGTH_LONG) .setAction(R.string.action_dismiss) { /* dismiss */ } .apply { this.view.initMarginWindowInsetsListener(bottom = true, consume = false) } .show() } viewModel.isLoading.observe(viewLifecycleOwner) { isLoading -> binding.loadingOverlay.isVisible = isLoading } } private fun checkForNewVersion() { Toast.makeText( requireContext(), R.string.info_update_checking_new_version, Toast.LENGTH_SHORT ).show() lifecycleScope.launch { when (val result = appUpdateRepository.checkForUpdates(BuildConfig.VERSION_NAME)) { is UpdateCheckResult.NewVersion -> { val release = result.release Notifications.showNewVersion(requireContext(), release) val message = getString( R.string.info_update_new_version_available_text, release.version ) Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG) .setAction(R.string.action_view) { openUrl(release.url) } .apply { this.view.initMarginWindowInsetsListener(bottom = true, consume = false) } .show() } is UpdateCheckResult.NoNewVersion -> { Toast.makeText( requireContext(), R.string.info_update_no_new_version_available, Toast.LENGTH_SHORT ).show() } is UpdateCheckResult.Error -> { Toast.makeText( requireContext(), R.string.info_update_failed, Toast.LENGTH_SHORT ).show() } } } } private fun observeUserModel() { viewModel.userModel.observe(this) { user -> //---- Title Language Preference findPreference(R.string.preference_key_titles)?.apply { entryValues = LocalTitleLanguagePreference.entries.map { it.name }.toTypedArray() setDefaultValue(KitsunePref.titles.name) value = KitsunePref.titles.name setOnPreferenceChangeListener { _, newValue -> val titlesPref = LocalTitleLanguagePreference.valueOf(newValue.toString()) KitsunePref.titles = titlesPref // Title preference can be also changed without being logged in. // Do only try to update the user model if logged in. if (user != null) { updateUserIfChanged( value, newValue, LocalUser.empty(user.id).copy( titleLanguagePreference = titlesPref ) ) } true } } //---- Country findPreference(R.string.preference_key_country)?.apply { entryValues = Locale.getISOCountries() entries = Locale.getISOCountries().map { Locale("", it).displayCountry }.toTypedArray() value = user?.country setOnPreferenceChangeListener { _, newValue -> if (user == null) return@setOnPreferenceChangeListener false updateUserIfChanged( value, newValue, LocalUser.empty(user.id).copy(country = newValue as String) ) true } requireUserLoggedIn(user) { if (it.value == null) { getString(R.string.preference_country_summary_non) } else { val countryName = Locale("", it.value).displayName getString(R.string.preference_country_summary, countryName) } } } //---- Adult Content findPreference(R.string.preference_key_sfw_filter)?.apply { value = user?.sfwFilterPreference?.name entryValues = LocalSfwFilterPreference.entries.map { it.name }.toTypedArray() setOnPreferenceChangeListener { _, newValue -> if (user == null) return@setOnPreferenceChangeListener false updateUserIfChanged( value, newValue, LocalUser.empty(user.id).copy( sfwFilterPreference = LocalSfwFilterPreference.valueOf(newValue as String) ) ) true } requireUserLoggedIn(user) { val filterPreference = it.value?.let { filter -> LocalSfwFilterPreference.valueOf(filter) } getString( when (filterPreference) { LocalSfwFilterPreference.SFW -> R.string.preference_adult_content_description_sfw LocalSfwFilterPreference.NSFW_SOMETIMES -> R.string.preference_adult_content_description_sometimes LocalSfwFilterPreference.NSFW_EVERYWHERE -> R.string.preference_adult_content_description_everywhere else -> R.string.no_information } ) } } //---- Rating System findPreference(R.string.preference_key_rating_system)?.apply { entryValues = LocalRatingSystemPreference.entries.reversed().map { it.name }.toTypedArray() value = user?.ratingSystem?.name setOnPreferenceChangeListener { _, newValue -> if (user == null) return@setOnPreferenceChangeListener false updateUserIfChanged( value, newValue, LocalUser.empty(user.id).copy( ratingSystem = LocalRatingSystemPreference.valueOf(newValue as String) ) ) true } requireUserLoggedIn(user, summaryProvider = SimpleSummaryProvider.getInstance()) } //---- Display Name findPreference(R.string.preference_key_display_name)?.apply { text = user?.name setOnPreferenceChangeListener { _, newValue -> if (user == null) return@setOnPreferenceChangeListener false updateUserIfChanged(text, newValue, LocalUser.empty(user.id).copy(name = newValue as String)) true } requireUserLoggedIn(user) { it.text } } //---- Profile URL findPreference(R.string.preference_key_profile_url)?.apply { text = user?.slug setOnPreferenceChangeListener { _, newValue -> if (user == null) return@setOnPreferenceChangeListener false updateUserIfChanged(text, newValue, LocalUser.empty(user.id).copy(slug = newValue as String)) true } requireUserLoggedIn(user) { if (!it.text.isNullOrBlank()) Kitsu.USER_URL_PREFIX + it.text else getString(R.string.preference_profile_url_not_set) } } } } private fun updateUserIfChanged(oldValue: Any?, newValue: Any?, user: LocalUser) { if (oldValue != newValue) { viewModel.updateUser(user) } } private inline fun T.requireUserLoggedIn( user: LocalUser?, @StringRes messageRes: Int = R.string.preference_not_logged_in, summaryProvider: Preference.SummaryProvider? = null ) { isEnabled = user != null if (user == null) { summary = getString(messageRes) } this.summaryProvider = if (user != null) { summaryProvider } else { null } } override fun onDestroyView() { viewModel.errorMessageListener = null super.onDestroyView() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/settings/SettingsViewModel.kt ================================================ package io.github.drumber.kitsune.ui.settings import android.content.Context import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.asLiveData import androidx.lifecycle.map import androidx.lifecycle.viewModelScope import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.exception.NoDataException import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.data.source.local.user.model.LocalUser import io.github.drumber.kitsune.domain.auth.IsUserLoggedInUseCase import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class SettingsViewModel( private val userRepository: UserRepository, isUserLoggedIn: IsUserLoggedInUseCase ) : ViewModel() { val userModel = userRepository.localUser.asLiveData().map { it } private val _isLoading = MutableLiveData(false) val isLoading: LiveData get() = _isLoading var errorMessageListener: ((ErrorMessage) -> Unit)? = null init { if (isUserLoggedIn()) { // make sure cached user data is up-to-date viewModelScope.launch(Dispatchers.IO) { try { userRepository.fetchAndStoreLocalUserFromNetwork() } catch (e: Exception) { logE("Failed to update local user model from network.", e) } } } } fun updateUser(user: LocalUser) { _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { try { userRepository.updateUser(user.id, user) ?: throw NoDataException("Received user data is null.") userRepository.fetchAndStoreLocalUserFromNetwork() } catch (e: Exception) { logE("Failed to update user settings.", e) errorMessageListener?.invoke(ErrorMessage(R.string.error_user_update_failed)) // workaround to trigger an update to reset preference values from the user model (userModel as MutableLiveData).postValue(userModel.value) } finally { _isLoading.postValue(false) } } } data class ErrorMessage( @StringRes val stringRes: Int? = null, val message: String = "" ) { fun getMessage(context: Context) = if (stringRes != null) { context.getString(stringRes) } else { message } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/settings/ThemePickerPreference.kt ================================================ package io.github.drumber.kitsune.ui.settings import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.ViewGroup import androidx.annotation.ColorRes import androidx.annotation.StringRes import androidx.preference.Preference import androidx.preference.PreferenceViewHolder import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.github.drumber.kitsune.R import io.github.drumber.kitsune.databinding.ItemThemeOptionBinding import io.github.drumber.kitsune.databinding.LayoutThemePickerPreferenceBinding import io.github.drumber.kitsune.util.extensions.getColor class ThemePickerPreference(context: Context, attrs: AttributeSet?) : Preference(context, attrs) { private lateinit var binding: LayoutThemePickerPreferenceBinding private val rvAdapter = ThemeAdapter() private var selectedTheme: ThemeEntry? = null override fun onBindViewHolder(holder: PreferenceViewHolder) { super.onBindViewHolder(holder) binding = LayoutThemePickerPreferenceBinding.bind(holder.itemView) binding.tvTitle.text = title binding.rvThemes.apply { layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false) adapter = rvAdapter } } fun setThemeEntries(entries: List) { rvAdapter.apply { themes.clear() themes.addAll(entries) notifyDataSetChanged() } } fun setSelectedTheme(theme: ThemeEntry) { val prevSelectedIndex = rvAdapter.themes.indexOf(selectedTheme) val newSelectedIndex = rvAdapter.themes.indexOf(theme) selectedTheme = theme if (prevSelectedIndex != -1) rvAdapter.notifyItemChanged(prevSelectedIndex) if (newSelectedIndex != -1) rvAdapter.notifyItemChanged(newSelectedIndex) } private fun onThemeCardClicked(theme: ThemeEntry) { if (callChangeListener(theme)) { setSelectedTheme(theme) } } data class ThemeEntry( @StringRes val name: Int, @ColorRes val primaryColor: Int, @ColorRes val secondaryColor: Int, @ColorRes val surfaceColor: Int ) private inner class ThemeAdapter(val themes: MutableList = mutableListOf()) : RecyclerView.Adapter() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ThemeViewHolder { return ThemeViewHolder( ItemThemeOptionBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun getItemCount(): Int = themes.size override fun onBindViewHolder(holder: ThemeViewHolder, position: Int) { holder.bind(themes[position]) } } private inner class ThemeViewHolder(private val binding: ItemThemeOptionBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(theme: ThemeEntry) { binding.apply { cardTheme.isEnabled = isEnabled cardTheme.isChecked = theme == selectedTheme cardTheme.setOnClickListener { onThemeCardClicked(theme) } viewPrimaryColor.setBackgroundResource(theme.primaryColor) viewSecondaryColor.setBackgroundResource(theme.secondaryColor) viewSurfaceColor.setBackgroundResource(theme.surfaceColor) tvName.isEnabled = isEnabled tvName.setText(theme.name) if (isEnabled) { tvName.setTextColor( context.theme.getColor( if (theme == selectedTheme) R.attr.colorPrimary else R.attr.colorOnSurface ) ) } } } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/theme/MdcThemeAdapter.kt ================================================ package io.github.drumber.kitsune.ui.theme import android.content.Context import android.content.res.TypedArray import androidx.annotation.StyleableRes import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.ui.graphics.Color import androidx.core.content.res.getColorOrThrow import androidx.core.content.res.use import io.github.drumber.kitsune.R fun obtainColorScheme( context: Context ): ColorScheme { return context.obtainStyledAttributes(R.styleable.MdcThemeAdapter).use { ta -> val primary = ta.parseColor(R.styleable.MdcThemeAdapter_colorPrimary) val onPrimary = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnPrimary) val primaryInverse = ta.parseColor(R.styleable.MdcThemeAdapter_colorPrimaryInverse) val primaryContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorPrimaryContainer) val onPrimaryContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnPrimaryContainer) val secondary = ta.parseColor(R.styleable.MdcThemeAdapter_colorSecondary) val onSecondary = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnSecondary) val secondaryContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorSecondaryContainer) val onSecondaryContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnSecondaryContainer) val tertiary = ta.parseColor(R.styleable.MdcThemeAdapter_colorTertiary) val onTertiary = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnTertiary) val tertiaryContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorTertiaryContainer) val onTertiaryContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnTertiaryContainer) val background = ta.parseColor(R.styleable.MdcThemeAdapter_android_colorBackground) val onBackground = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnBackground) val surface = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurface) val onSurface = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnSurface) val surfaceVariant = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceVariant) val onSurfaceVariant = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnSurfaceVariant) val elevationOverlay = ta.parseColor(R.styleable.MdcThemeAdapter_elevationOverlayColor) val surfaceInverse = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceInverse) val onSurfaceInverse = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnSurfaceInverse) val outline = ta.parseColor(R.styleable.MdcThemeAdapter_colorOutline) val outlineVariant = ta.parseColor(R.styleable.MdcThemeAdapter_colorOutlineVariant) val error = ta.parseColor(R.styleable.MdcThemeAdapter_colorError) val onError = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnError) val errorContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorErrorContainer) val onErrorContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorOnErrorContainer) val scrimBackground = ta.parseColor(R.styleable.MdcThemeAdapter_scrimBackground) val surfaceBright = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceBright) val surfaceContainer = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceContainer) val surfaceContainerHigh = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceContainerHigh) val surfaceContainerHighest = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceContainerHighest) val surfaceContainerLow = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceContainerLow) val surfaceContainerLowest = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceContainerLowest) val surfaceDim = ta.parseColor(R.styleable.MdcThemeAdapter_colorSurfaceDim) val isLightTheme = ta.getBoolean(R.styleable.MdcThemeAdapter_isLightTheme, true) if (isLightTheme) { lightColorScheme( primary = primary, onPrimary = onPrimary, primaryContainer = primaryContainer, onPrimaryContainer = onPrimaryContainer, inversePrimary = primaryInverse, secondary = secondary, onSecondary = onSecondary, secondaryContainer = secondaryContainer, onSecondaryContainer = onSecondaryContainer, tertiary = tertiary, onTertiary = onTertiary, tertiaryContainer = tertiaryContainer, onTertiaryContainer = onTertiaryContainer, background = background, onBackground = onBackground, surface = surface, onSurface = onSurface, surfaceVariant = surfaceVariant, onSurfaceVariant = onSurfaceVariant, surfaceTint = elevationOverlay, inverseSurface = surfaceInverse, inverseOnSurface = onSurfaceInverse, error = error, onError = onError, errorContainer = errorContainer, onErrorContainer = onErrorContainer, outline = outline, outlineVariant = outlineVariant, scrim = scrimBackground, surfaceBright = surfaceBright, surfaceContainer = surfaceContainer, surfaceContainerHigh = surfaceContainerHigh, surfaceContainerHighest = surfaceContainerHighest, surfaceContainerLow = surfaceContainerLow, surfaceContainerLowest = surfaceContainerLowest, surfaceDim = surfaceDim, ) } else { darkColorScheme( primary = primary, onPrimary = onPrimary, primaryContainer = primaryContainer, onPrimaryContainer = onPrimaryContainer, inversePrimary = primaryInverse, secondary = secondary, onSecondary = onSecondary, secondaryContainer = secondaryContainer, onSecondaryContainer = onSecondaryContainer, tertiary = tertiary, onTertiary = onTertiary, tertiaryContainer = tertiaryContainer, onTertiaryContainer = onTertiaryContainer, background = background, onBackground = onBackground, surface = surface, onSurface = onSurface, surfaceVariant = surfaceVariant, onSurfaceVariant = onSurfaceVariant, surfaceTint = elevationOverlay, inverseSurface = surfaceInverse, inverseOnSurface = onSurfaceInverse, error = error, onError = onError, errorContainer = errorContainer, onErrorContainer = onErrorContainer, outline = outline, outlineVariant = outlineVariant, scrim = scrimBackground, surfaceBright = surfaceBright, surfaceContainer = surfaceContainer, surfaceContainerHigh = surfaceContainerHigh, surfaceContainerHighest = surfaceContainerHighest, surfaceContainerLow = surfaceContainerLow, surfaceContainerLowest = surfaceContainerLowest, surfaceDim = surfaceDim, ) } } } private fun TypedArray.parseColor(@StyleableRes index: Int): Color { return Color(getColorOrThrow(index)) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/theme/Theme.kt ================================================ package io.github.drumber.kitsune.ui.theme import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLayoutDirection import com.google.accompanist.themeadapter.material3.createMdc3Theme @Composable fun KitsuneTheme( darkTheme: Boolean = isSystemInDarkTheme(), dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val context = LocalContext.current val layoutDirection = LocalLayoutDirection.current val (_, typography, shapes) = createMdc3Theme( context = context, layoutDirection = layoutDirection, readColorScheme = false ) val useDynamicColor = dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S val colorScheme = when { useDynamicColor && darkTheme -> dynamicDarkColorScheme(context) useDynamicColor && !darkTheme -> dynamicLightColorScheme(context) else -> obtainColorScheme(context) } MaterialTheme( colorScheme = colorScheme, typography = typography!!, shapes = shapes!!, content = content ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/webview/WebViewFragment.kt ================================================ package io.github.drumber.kitsune.ui.webview import android.content.Intent import android.graphics.Bitmap import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.activity.addCallback import androidx.core.net.toUri import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.source.local.auth.model.LocalAccessToken import io.github.drumber.kitsune.databinding.FragmentWebViewBinding import io.github.drumber.kitsune.util.extensions.copyToClipboard import io.github.drumber.kitsune.util.extensions.openUrl import io.github.drumber.kitsune.util.ui.initPaddingWindowInsetsListener import io.github.drumber.kitsune.util.ui.initWindowInsetsListener import org.koin.android.ext.android.inject class WebViewFragment : Fragment() { private var _binding: FragmentWebViewBinding? = null private val binding get() = _binding!! private val args: WebViewFragmentArgs by navArgs() private val accessTokenRepository: AccessTokenRepository by inject() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentWebViewBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) binding.toolbar.apply { initWindowInsetsListener(false) subtitle = args.url setNavigationOnClickListener { findNavController().navigateUp() } setOnMenuItemClickListener(::onToolbarMenuItemClicked) } binding.webViewWrapper.initPaddingWindowInsetsListener( left = true, right = true, consume = false ) binding.webView.apply { settings.javaScriptEnabled = true settings.domStorageEnabled = true webViewClient = KitsuWebViewClient(accessTokenRepository::getAccessToken) webChromeClient = KitsuWebChromeClient() if (savedInstanceState == null) { loadUrl(args.url) } else { restoreState(savedInstanceState) } } requireActivity().onBackPressedDispatcher.addCallback(this) { if (binding.webView.canGoBack()) { binding.webView.goBack() } else { findNavController().navigateUp() } } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) binding.webView.saveState(outState) } private fun onToolbarMenuItemClicked(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_open_in_browser -> { binding.webView.url?.let { openUrl(it) } } R.id.menu_copy_url -> { binding.webView.url?.let { copyToClipboard("URL", it) } } else -> return false } return true } inner class KitsuWebChromeClient : WebChromeClient() { override fun onReceivedTitle(view: WebView?, title: String?) { super.onReceivedTitle(view, title) binding.toolbar.title = title } } inner class KitsuWebViewClient( private val getAccessToken: () -> LocalAccessToken? ) : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) if (url?.toUri()?.host in validKitsuHosts) { val accessToken = getAccessToken() if (accessToken != null) { view?.evaluateJavascript(getAccessTokenInjectionCode(accessToken), null) } } binding.toolbar.subtitle = url } override fun onPageFinished(view: WebView?, url: String?) { super.onPageFinished(view, url) binding.loadingIndicator.isVisible = false } override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { super.doUpdateVisitedHistory(view, url, isReload) binding.toolbar.subtitle = url } override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean { if (request?.url?.host in validKitsuHosts) { return false } val browseIntent = Intent(Intent.ACTION_VIEW, request?.url) startActivity(browseIntent) return true } private fun getAccessTokenInjectionCode(accessToken: LocalAccessToken): String { val expiresAt = (accessToken.createdAt + accessToken.expiresIn) * 1000 val emberSession = "{\"authenticated\":{\"authenticator\":\"authenticator:oauth2\",\"access_token\":\"${accessToken.accessToken}\",\"token_type\":\"Bearer\",\"expires_in\":${accessToken.expiresIn},\"refresh_token\":\"${accessToken.refreshToken}\",\"scope\":\"public\",\"created_at\":${accessToken.createdAt},\"expires_at\":${expiresAt}}}" return "window.localStorage.setItem(\"ember_simple_auth:session\", '$emberSession');" } } companion object { private val validKitsuHosts = listOf("kitsu.app", "kitsu.io") } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/widget/KitsuneWidgetReceiver.kt ================================================ package io.github.drumber.kitsune.ui.widget import android.appwidget.AppWidgetManager import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.work.Constraints import androidx.work.ExistingWorkPolicy import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.work.SyncLibraryEntriesForWidgetWorker import kotlin.time.Duration.Companion.minutes class KitsuneWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = LibraryAppWidget() override fun onUpdate( context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray ) { if (shouldSyncLibrary()) { enqueueSyncLibraryWork(context) } super.onUpdate(context, appWidgetManager, appWidgetIds) } private fun shouldSyncLibrary(): Boolean { val lastSyncMillis = KitsunePref.lastLibraryFetchForWidget return lastSyncMillis == -1L || System.currentTimeMillis() >= (lastSyncMillis + 5.minutes.inWholeMilliseconds) } private fun enqueueSyncLibraryWork(context: Context) { val workConstraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val syncWork = OneTimeWorkRequestBuilder() .setConstraints(workConstraints) .build() WorkManager.getInstance(context).enqueueUniqueWork( SyncLibraryEntriesForWidgetWorker.TAG, ExistingWorkPolicy.KEEP, syncWork ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/widget/KitsuneWidgetTheme.kt ================================================ package io.github.drumber.kitsune.ui.widget import android.annotation.SuppressLint import android.content.Context import androidx.compose.runtime.Composable import androidx.glance.GlanceTheme import androidx.glance.LocalContext import androidx.glance.color.ColorProviders import androidx.glance.color.colorProviders import androidx.glance.unit.ColorProvider import io.github.drumber.kitsune.R import io.github.drumber.kitsune.constants.AppTheme import io.github.drumber.kitsune.util.extensions.getResourceId object KitsuneWidgetTheme { fun Context.applyTheme(appTheme: AppTheme) { setTheme(appTheme.themeRes) } @Composable fun getColors(useDynamicColorTheme: Boolean): ColorProviders { if (useDynamicColorTheme) { return GlanceTheme.colors } return getColorSchemeFromAppTheme(LocalContext.current) } @SuppressLint("RestrictedApi") private fun getColorSchemeFromAppTheme(context: Context): ColorProviders { return colorProviders( primary = ColorProvider(context.theme.getResourceId(R.attr.colorPrimary)), onPrimary = ColorProvider(context.theme.getResourceId(R.attr.colorOnPrimary)), primaryContainer = ColorProvider(context.theme.getResourceId(R.attr.colorPrimaryContainer)), onPrimaryContainer = ColorProvider(context.theme.getResourceId(R.attr.colorOnPrimaryContainer)), secondary = ColorProvider(context.theme.getResourceId(R.attr.colorSecondary)), onSecondary = ColorProvider(context.theme.getResourceId(R.attr.colorOnSecondary)), secondaryContainer = ColorProvider(context.theme.getResourceId(R.attr.colorSecondaryContainer)), onSecondaryContainer = ColorProvider(context.theme.getResourceId(R.attr.colorOnSecondaryContainer)), tertiary = ColorProvider(context.theme.getResourceId(R.attr.colorTertiary)), onTertiary = ColorProvider(context.theme.getResourceId(R.attr.colorOnTertiary)), tertiaryContainer = ColorProvider(context.theme.getResourceId(R.attr.colorTertiaryContainer)), onTertiaryContainer = ColorProvider(context.theme.getResourceId(R.attr.colorOnTertiaryContainer)), error = ColorProvider(context.theme.getResourceId(R.attr.colorError)), errorContainer = ColorProvider(context.theme.getResourceId(R.attr.colorErrorContainer)), onError = ColorProvider(context.theme.getResourceId(R.attr.colorOnError)), onErrorContainer = ColorProvider(context.theme.getResourceId(R.attr.colorOnErrorContainer)), background = ColorProvider(context.theme.getResourceId(android.R.attr.colorBackground)), onBackground = ColorProvider(context.theme.getResourceId(R.attr.colorOnBackground)), surface = ColorProvider(context.theme.getResourceId(R.attr.colorSurface)), onSurface = ColorProvider(context.theme.getResourceId(R.attr.colorOnSurface)), surfaceVariant = ColorProvider(context.theme.getResourceId(R.attr.colorSurfaceVariant)), onSurfaceVariant = ColorProvider(context.theme.getResourceId(R.attr.colorOnSurfaceVariant)), outline = ColorProvider(context.theme.getResourceId(R.attr.colorOutline)), inverseOnSurface = ColorProvider(context.theme.getResourceId(R.attr.colorOnSurfaceInverse)), inverseSurface = ColorProvider(context.theme.getResourceId(R.attr.colorSurfaceInverse)), inversePrimary = ColorProvider(context.theme.getResourceId(R.attr.colorPrimaryInverse)), widgetBackground = ColorProvider(context.theme.getResourceId(R.attr.colorSurface)), ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/widget/LibraryAppWidget.kt ================================================ package io.github.drumber.kitsune.ui.widget import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.glance.Button import androidx.glance.GlanceId import androidx.glance.GlanceModifier import androidx.glance.GlanceTheme import androidx.glance.Image import androidx.glance.ImageProvider import androidx.glance.LocalContext import androidx.glance.action.Action import androidx.glance.action.clickable import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.LinearProgressIndicator import androidx.glance.appwidget.SizeMode import androidx.glance.appwidget.action.actionStartActivity import androidx.glance.appwidget.components.Scaffold import androidx.glance.appwidget.components.SquareIconButton import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.lazy.LazyColumn import androidx.glance.appwidget.lazy.items import androidx.glance.appwidget.provideContent import androidx.glance.appwidget.updateAll import androidx.glance.background import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.Column import androidx.glance.layout.Row import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width import androidx.glance.layout.wrapContentHeight import androidx.glance.text.FontWeight import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import androidx.lifecycle.asFlow import com.bumptech.glide.Glide import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.load.resource.bitmap.RoundedCorners import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target import com.chibatching.kotpref.livedata.asLiveData import io.github.drumber.kitsune.R import io.github.drumber.kitsune.addTransform import io.github.drumber.kitsune.constants.IntentAction.OPEN_LIBRARY import io.github.drumber.kitsune.constants.IntentAction.OPEN_MEDIA import io.github.drumber.kitsune.constants.LibraryWidget import io.github.drumber.kitsune.data.presentation.dto.toMediaDto import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntry import io.github.drumber.kitsune.data.presentation.model.library.LibraryEntryWithModification import io.github.drumber.kitsune.data.presentation.model.library.LibraryStatus import io.github.drumber.kitsune.data.presentation.model.media.identifier import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.data.repository.AccessTokenRepository.AccessTokenState import io.github.drumber.kitsune.data.repository.LibraryRepository import io.github.drumber.kitsune.domain.auth.IsUserLoggedInUseCase import io.github.drumber.kitsune.domain.library.UpdateLibraryEntryProgressUseCase import io.github.drumber.kitsune.preference.KitsunePref import io.github.drumber.kitsune.ui.details.DetailsFragmentArgs import io.github.drumber.kitsune.ui.main.MainActivity import io.github.drumber.kitsune.ui.widget.KitsuneWidgetTheme.applyTheme import io.github.drumber.kitsune.util.logE import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.cancelFutureOnCancellation import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException class LibraryAppWidget : GlanceAppWidget(), KoinComponent { companion object { private const val POSTER_IMG_WIDTH = 60 private const val POSTER_IMG_HEIGHT = 85 } private val isLoggedIn: IsUserLoggedInUseCase by inject() private val accessTokenRepository: AccessTokenRepository by inject() private val libraryRepository: LibraryRepository by inject() private val updateLibraryEntryProgress: UpdateLibraryEntryProgressUseCase by inject() override val sizeMode: SizeMode = SizeMode.Single override suspend fun provideGlance(context: Context, id: GlanceId) { val initialEntries = loadData() provideContent { val appTheme by KitsunePref.asLiveData(KitsunePref::appTheme) .asFlow() .collectAsState(initial = KitsunePref.appTheme) val useDynamicColorTheme by KitsunePref.asLiveData(KitsunePref::useDynamicColorTheme) .asFlow() .collectAsState(initial = KitsunePref.useDynamicColorTheme) LocalContext.current.applyTheme(appTheme) val scope = rememberCoroutineScope() val entries by getDataFlow().collectAsState(initial = initialEntries) GlanceTheme(colors = KitsuneWidgetTheme.getColors(useDynamicColorTheme)) { Scaffold(horizontalPadding = 0.dp) { WidgetContent( entries = entries, clickItemAction = { libraryEntry -> val intent = getMainActivityIntent(context).apply { action = OPEN_MEDIA val args = DetailsFragmentArgs( media = libraryEntry.media?.toMediaDto(), type = libraryEntry.media?.mediaType?.identifier, slug = libraryEntry.media?.id ) putExtras(args.toBundle()) } actionStartActivity(intent) }, progressAction = { libraryEntry, progress -> scope.launch { withContext(Dispatchers.IO) { updateLibraryEntryProgress(libraryEntry, progress) } updateAll(context) } }, emptyListAction = { val intent = getMainActivityIntent(context).apply { action = OPEN_LIBRARY } actionStartActivity(intent) } ) } } } } @Composable private fun WidgetContent( entries: List, clickItemAction: (LibraryEntry) -> Action, progressAction: (LibraryEntry, Int) -> Unit, emptyListAction: () -> Action ) { if (entries.isNotEmpty()) { val padding = 8.dp val itemBackgroundColor = GlanceTheme.colors.surfaceVariant LazyColumn( modifier = GlanceModifier .fillMaxSize() ) { items(items = entries, itemId = { item -> item.id.toLong() }) { item -> val isLastItem = entries.lastOrNull()?.id == item.id val itemCardModifier = GlanceModifier .wrapContentHeight() .fillMaxWidth() .background(itemBackgroundColor) .cornerRadiusCompat { itemBackgroundColor } Box( modifier = GlanceModifier.padding( start = padding, top = padding, end = padding, bottom = if (isLastItem) padding else 0.dp ) ) { LibraryItem( item = item, modifier = itemCardModifier, clickItemAction = clickItemAction, progressAction = progressAction ) } } } } else { EmptyView(emptyListAction) } } @Composable private fun LibraryItem( item: LibraryEntryWithModification, modifier: GlanceModifier, clickItemAction: (LibraryEntry) -> Action, progressAction: (LibraryEntry, Int) -> Unit ) { val context = LocalContext.current val posterCornerRadius = innerCornerRadius(context) val posterUrl = item.media?.posterImageUrl var posterImage by remember(posterUrl) { mutableStateOf(null) } LaunchedEffect(posterUrl) { try { posterImage = loadBitmap(context, posterUrl, posterCornerRadius) } catch (e: Exception) { logE("Failed to load poster image for URL $posterUrl", e) } } val imageProvider = posterImage?.let { ImageProvider(it) } ?: ImageProvider(R.drawable.ic_insert_photo_48) Row( modifier = modifier.clickable(clickItemAction(item.libraryEntry)) ) { Image( provider = imageProvider, contentDescription = null, modifier = GlanceModifier .size(width = POSTER_IMG_WIDTH.dp, height = POSTER_IMG_HEIGHT.dp) .applyIf(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { cornerRadius(android.R.dimen.system_app_widget_inner_radius) } ) Box(contentAlignment = Alignment.BottomEnd) { Column( modifier = GlanceModifier .fillMaxSize() .padding(start = 12.dp, top = 12.dp, end = 12.dp, bottom = 0.dp) ) { Text( text = item.media?.title ?: "", style = TextStyle( color = GlanceTheme.colors.onSurface, fontSize = 16.sp, fontWeight = FontWeight.Medium ), maxLines = 1 ) Spacer(GlanceModifier.height(4.dp)) Text( text = item.media?.subtypeFormatted ?: "", style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Normal ) ) } if (item.hasEpisodesCount && item.hasStartedWatching) { val episodeCount = item.episodeCount?.coerceAtLeast(1) ?: 1 val progress = item.progress ?: 0 LinearProgressIndicator( progress = progress.toFloat() / episodeCount, color = GlanceTheme.colors.primary, backgroundColor = GlanceTheme.colors.secondaryContainer, modifier = GlanceModifier .fillMaxWidth() .height(4.dp) .applyIf(Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // corner clipping is not supported on android < S // to avoid overflow of the progress bar, we add padding to the end padding(end = 12.dp) } ) } Row( verticalAlignment = Alignment.Bottom, modifier = GlanceModifier.padding(bottom = 12.dp, end = 12.dp) ) { val progressText = when (item.progress) { null, 0 -> context.getString(R.string.library_not_started) else -> "${item.progress ?: 0}/${item.episodeCountFormatted}" } Text( text = progressText, style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Normal ) ) Spacer(GlanceModifier.width(8.dp)) SquareIconButton( imageProvider = ImageProvider(R.drawable.ic_add_24), contentDescription = null, modifier = GlanceModifier.size(48.dp), onClick = { progressAction(item.libraryEntry, item.progress?.plus(1) ?: 1) } ) } } } } @Composable fun EmptyView(action: () -> Action) { val context = LocalContext.current Column( verticalAlignment = Alignment.Vertical.CenterVertically, horizontalAlignment = Alignment.Horizontal.CenterHorizontally, modifier = GlanceModifier.fillMaxSize().padding(4.dp) ) { Text( text = context.getString(R.string.widget_empty_text), style = TextStyle( color = GlanceTheme.colors.onSurfaceVariant, fontSize = 16.sp, textAlign = TextAlign.Center ) ) Spacer(GlanceModifier.height(10.dp)) Button( text = context.getString(R.string.widget_empty_action), onClick = action() ) } } private suspend fun loadData(): List { if (!isLoggedIn()) return emptyList() return try { libraryRepository.getLibraryEntriesWithModificationsByStatus( listOf(LibraryStatus.Current) ).take(LibraryWidget.MAX_ITEM_COUNT) } catch (e: Exception) { logE("Failed to get library entries.", e) emptyList() } } @OptIn(ExperimentalCoroutinesApi::class) private fun getDataFlow(): Flow> { return try { accessTokenRepository.accessTokenState.flatMapLatest { state -> if (state == AccessTokenState.PRESENT) { libraryRepository.getLibraryEntriesWithModificationsByStatusAsFlow( listOf(LibraryStatus.Current) ).map { it.take(LibraryWidget.MAX_ITEM_COUNT) } } else { emptyFlow() } } } catch (e: Exception) { logE("Failed to get library entries flow.", e) emptyFlow() } } private suspend fun loadBitmap( context: Context, url: String?, cornerRadius: Int ) = suspendCancellableCoroutine { cont -> val request = Glide.with(context) .asBitmap() .load(url) .addTransform(RoundedCorners(cornerRadius)) .listener(object : RequestListener { override fun onLoadFailed( e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean ): Boolean { cont.resumeWithException(e ?: Exception("Image failed to load.")) return false } override fun onResourceReady( resource: Bitmap, model: Any, target: Target?, dataSource: DataSource, isFirstResource: Boolean ): Boolean { cont.resume(resource) return false } }) .submit() cont.cancelFutureOnCancellation(request) } private fun getMainActivityIntent(context: Context): Intent { return Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME)) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/ui/widget/WidgetUtils.kt ================================================ package io.github.drumber.kitsune.ui.widget import android.content.Context import android.os.Build import androidx.glance.ColorFilter import androidx.glance.GlanceModifier import androidx.glance.ImageProvider import androidx.glance.appwidget.cornerRadius import androidx.glance.background import androidx.glance.unit.ColorProvider import io.github.drumber.kitsune.R import io.github.drumber.kitsune.util.extensions.toPx fun GlanceModifier.applyIf( condition: Boolean, block: GlanceModifier.() -> GlanceModifier ): GlanceModifier { return if (condition) { block() } else { this } } fun innerCornerRadius(context: Context, fallbackRadius: Int = 20): Int { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { context.resources.getDimensionPixelSize(android.R.dimen.system_app_widget_inner_radius) } else { fallbackRadius.toPx() } } fun GlanceModifier.cornerRadiusCompat( backgroundColorProvider: () -> ColorProvider ): GlanceModifier { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { cornerRadius(android.R.dimen.system_app_widget_inner_radius) } else { background( ImageProvider(R.drawable.widget_rounded_rect), colorFilter = ColorFilter.tint(backgroundColorProvider()) ) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/DataUtil.kt ================================================ package io.github.drumber.kitsune.util import android.content.Context import io.github.drumber.kitsune.R import io.github.drumber.kitsune.data.common.Titles import io.github.drumber.kitsune.data.common.en import io.github.drumber.kitsune.data.common.enJp import io.github.drumber.kitsune.data.common.enUs import io.github.drumber.kitsune.data.common.jaJp import io.github.drumber.kitsune.data.source.local.user.model.LocalTitleLanguagePreference import io.github.drumber.kitsune.preference.KitsunePref import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale object DataUtil { @JvmStatic fun formatDate(dateString: String?) = dateString?.parseDate()?.formatDate(SimpleDateFormat.LONG) @JvmStatic fun getGenderString(gender: String?, context: Context): String { return when (gender) { "male" -> context.getString(R.string.profile_gender_male) "female" -> context.getString(R.string.profile_gender_female) "secret", null -> context.getString(R.string.profile_data_private) else -> gender } } @JvmStatic fun formatUserJoinDate(joinDate: String?, context: Context): String? { return joinDate?.parseDate()?.let { dateJoined -> val diffMillis = Calendar.getInstance().timeInMillis - dateJoined.time val differenceString = TimeUtil.roundTime(diffMillis / 1000, context) "${dateJoined.formatDate(SimpleDateFormat.LONG)} " + "(${context.getString(R.string.profile_data_join_date_ago, differenceString)})" } } @JvmStatic fun getTitle(title: Titles?, canonical: String?): String? { return when (KitsunePref.titles) { LocalTitleLanguagePreference.Canonical -> canonical.nb() ?: title?.enJp.nb() ?: title?.en.nb() ?: title?.enUs.nb() ?: title?.jaJp LocalTitleLanguagePreference.Romanized -> title?.enJp.nb() ?: canonical.nb() ?: title?.en.nb() ?: title?.enUs.nb() ?: title?.jaJp LocalTitleLanguagePreference.English -> title?.en.nb() ?: title?.enUs.nb() ?: canonical.nb() ?: title?.enJp.nb() ?: title?.jaJp } } @JvmStatic fun Map.mapLanguageCodesToDisplayName(includeCountry: Boolean = true): Map { return mapKeys { val locale = Locale.forLanguageTag(it.key.replace('_', '-')) if (includeCountry && it.key.lowercase().split('_').toSet().size > 1) locale.displayName else locale.displayLanguage } } /** * Maps blank strings to null. */ private fun String?.nb() = if (this.isNullOrBlank()) null else this } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/DateUtil.kt ================================================ package io.github.drumber.kitsune.util import java.text.ParseException import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale import java.util.TimeZone const val DATE_FORMAT_ISO = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" fun String.parseDate( format: String = "yyyy-MM-dd", timeZoneOfDateString: TimeZone = TimeZone.getDefault() ): Date? { if (this.isBlank()) { return null } val dateFormat = SimpleDateFormat(format, Locale.getDefault()) dateFormat.timeZone = timeZoneOfDateString return try { dateFormat.parse(this) } catch (_: ParseException) { null } } fun String.parseUtcDate(format: String = DATE_FORMAT_ISO) = parseDate(format, TimeZone.getTimeZone("UTC")) fun Date.formatDate(dateFormat: Int = SimpleDateFormat.DEFAULT): String { val format = SimpleDateFormat.getDateInstance(dateFormat) return format.format(this) } fun Date.formatDate(pattern: String, timeZone: TimeZone = TimeZone.getDefault()): String { val format = SimpleDateFormat(pattern, Locale.getDefault()) format.timeZone = timeZone return format.format(this) } fun Date.formatUtcDate(pattern: String = DATE_FORMAT_ISO) = formatDate(pattern, TimeZone.getTimeZone("UTC")) fun Calendar.formatDate(dateFormat: Int = SimpleDateFormat.DEFAULT) = time.formatDate(dateFormat) fun Calendar.formatDate(pattern: String, timeZone: TimeZone = TimeZone.getDefault()) = time.formatDate(pattern, timeZone) fun Calendar.formatUtcDate(pattern: String = DATE_FORMAT_ISO) = time.formatUtcDate(pattern) fun getLocalCalendar(): Calendar = Calendar.getInstance() fun getUtcCalendar(rawCalendar: Calendar? = null): Calendar { val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) if (rawCalendar == null) { calendar.clear() } else { calendar.timeInMillis = rawCalendar.timeInMillis } return calendar } fun getDayCopyInUtc(rawCalendar: Calendar): Calendar { val rawCalendarInUtc = getUtcCalendar(rawCalendar) val utcCalendar = getUtcCalendar() utcCalendar.set( rawCalendarInUtc.get(Calendar.YEAR), rawCalendarInUtc.get(Calendar.MONTH), rawCalendarInUtc.get(Calendar.DAY_OF_MONTH) ) return utcCalendar } /** * Keeps only year, month and day information of the specified time in milliseconds. */ fun stripTimeOfUtcMillis(rawDate: Long): Long { val rawCalendar = getUtcCalendar() rawCalendar.timeInMillis = rawDate val sanitizedStartItem = getDayCopyInUtc(rawCalendar) return sanitizedStartItem.timeInMillis } /** * Keeps only year, month and day information of the specified time in milliseconds. */ fun Long.stripTimeUtcMillis() = stripTimeOfUtcMillis(this) fun Long.toDate(): Date { val calendar = Calendar.getInstance() calendar.timeInMillis = this return calendar.time } fun Date.toCalendar(): Calendar { val calendar = Calendar.getInstance() calendar.time = this return calendar } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/ItemClickListener.kt ================================================ package io.github.drumber.kitsune.util fun interface ItemClickListener { fun onItemClicked() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/KitsuUrlReplacer.kt ================================================ package io.github.drumber.kitsune.util /** * Replaces the media URL from the old kitsu.io domain to the new kitsu.app domain. * * Added on 2024-08-11 due to sudden domain change. Algolia search results are still using the old media domain. * Related PR: https://github.com/Drumber/Kitsune/pull/57 * * TODO: Can be removed once Kitsu has fully migrated to the new domain. */ fun String.fixImageUrl() = replaceFirst("media.kitsu.io", "media.kitsu.app") ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/LogCatReader.kt ================================================ package io.github.drumber.kitsune.util import android.os.Build import io.github.drumber.kitsune.BuildConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.util.Date object LogCatReader { suspend fun readAppLogs(maxLines: Int = 5000) = withContext(Dispatchers.IO) { val logLevelFilter = when (BuildConfig.DEBUG) { true -> "*:D" false -> "*:I" } val process = Runtime.getRuntime().exec("logcat -d -t $maxLines $logLevelFilter") process.inputStream.bufferedReader().use { return@withContext it.readLines() } } suspend fun writeAppLogsToFile( file: File, writeHeader: Boolean = true ) = withContext(Dispatchers.IO) { val logs = readAppLogs() file.parentFile?.mkdirs() file.bufferedWriter().use { writer -> if (writeHeader) writer.appendLine(generateLogFileHeader()) logs.forEach { line -> writer.appendLine(line) } } } private fun generateLogFileHeader(): String { return StringBuilder() .appendLine("###########################################################") .appendLine("Log file generated on ${Date()}") .appendLine("Kitsune version: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})") .appendLine("Application ID: ${BuildConfig.APPLICATION_ID}") .appendLine("Build type: ${BuildConfig.BUILD_TYPE}") .appendLine("Android version: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") .appendLine("Device: ${Build.MANUFACTURER} ${Build.MODEL}") .appendLine("###########################################################") .toString() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/LogUtil.kt ================================================ package io.github.drumber.kitsune.util import android.util.Log inline fun Any.logV(msg: String, t: Throwable? = null) { if (t == null) Log.v(this::class.java.simpleName, msg) else Log.v(this::class.java.simpleName, msg, t) } inline fun Any.logD(msg: String, t: Throwable? = null) { if (t == null) Log.d(this::class.java.simpleName, msg) else Log.d(this::class.java.simpleName, msg, t) } inline fun Any.logI(msg: String, t: Throwable? = null) { if (t == null) Log.i(this::class.java.simpleName, msg) else Log.i(this::class.java.simpleName, msg, t) } inline fun Any.logW(msg: String, t: Throwable? = null) { if (t == null) Log.w(this::class.java.simpleName, msg) else Log.w(this::class.java.simpleName, msg, t) } inline fun Any.logE(msg: String, t: Throwable? = null) { if (t == null) Log.e(this::class.java.simpleName, msg) else Log.e(this::class.java.simpleName, msg, t) } inline fun Any.logWTF(msg: String, t: Throwable? = null) { if (t == null) Log.wtf(this::class.java.simpleName, msg) else Log.wtf(this::class.java.simpleName, msg, t) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/SaveImage.kt ================================================ package io.github.drumber.kitsune.util import android.content.ContentValues import android.content.Context import android.graphics.Bitmap import android.os.Build import android.os.Environment import android.provider.MediaStore import java.util.* fun Context.saveImageInGallery(image: Bitmap, imageName: String? = null): Boolean { val fileName = (if (!imageName.isNullOrBlank()) "${imageName}_" else "") + Date().formatDate("yyyy-MM-dd-HH-mm-ss") + ".jpg" val contentValues = ContentValues().apply { if (!imageName.isNullOrBlank()) put(MediaStore.MediaColumns.TITLE, imageName) put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg") put(MediaStore.MediaColumns.DATE_ADDED, System.currentTimeMillis() / 1000) put(MediaStore.MediaColumns.DATE_MODIFIED, System.currentTimeMillis() / 1000) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.MediaColumns.DATE_TAKEN, System.currentTimeMillis()) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_PICTURES) } } val imageUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues) val outputStream = imageUri?.let { contentResolver.openOutputStream(it) } outputStream?.use { logD("Saving image to $imageUri") image.compress(Bitmap.CompressFormat.JPEG, 100, it) return true } return false } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/TimeUtil.kt ================================================ package io.github.drumber.kitsune.util import android.content.Context import io.github.drumber.kitsune.R import io.github.drumber.kitsune.util.extensions.format import java.math.RoundingMode import kotlin.math.roundToInt import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.DurationUnit import kotlin.time.ExperimentalTime object TimeUtil { /** * Formats the given time in seconds to a human readable time string like: * `2 years, 1 month, 5 days, 1 hour, 2 minutes` */ @OptIn(ExperimentalTime::class) fun timeToHumanReadableFormat( timeSeconds: Long, context: Context, includeSeconds: Boolean = false ): String { val res = context.resources val parts = mutableListOf() var remaining = timeSeconds.seconds val years = remaining.inWholeYears if (years > 0) { parts += res.getQuantityString(R.plurals.duration_years, years.toInt(), years) remaining -= yearsToDuration(years) } val months = remaining.inWholeMonths if (months > 0) { parts += res.getQuantityString(R.plurals.duration_months, months.toInt(), months) remaining -= monthsToDuration(months) } val days = remaining.inWholeDays if (days > 0) { parts += res.getQuantityString(R.plurals.duration_days, days.toInt(), days) remaining -= days.days } val hours = remaining.inWholeHours if (hours > 0) { parts += res.getQuantityString(R.plurals.duration_hours, hours.toInt(), hours) remaining -= hours.hours } val minutes = remaining.inWholeMinutes if (minutes > 0) { parts += res.getQuantityString(R.plurals.duration_minutes, minutes.toInt(), minutes) remaining -= minutes.minutes } val seconds = remaining.inWholeSeconds if (includeSeconds && seconds > 0) { parts += res.getQuantityString(R.plurals.duration_seconds, seconds.toInt(), seconds) } return parts.joinToString(", ") } /** * Formats the given time in seconds to a rounded time string. * * Example: * ``` * 1s -> 1 second * 59s -> 59 seconds * 60s -> 1 minute * 90s -> 1.5 minutes * 3600s -> 1 hour * ``` */ @OptIn(ExperimentalTime::class) fun roundTime(timeSeconds: Long, context: Context, decimalPlaces: Int = 1): String { val res = context.resources val time = timeSeconds.seconds return when { time.inWholeYears > 0 -> { val years = time.toYearsDouble().round(decimalPlaces) res.getQuantityString(R.plurals.duration_years, years.roundToInt(), years.format()) } time.inWholeMonths > 0 -> { val months = time.toMonthsDouble().round(decimalPlaces) res.getQuantityString(R.plurals.duration_months, months.roundToInt(), months.format()) } time.inWholeDays > 0 -> { val days = time.toDouble(DurationUnit.DAYS).round(decimalPlaces) res.getQuantityString(R.plurals.duration_days, days.roundToInt(), days.format()) } time.inWholeHours > 0 -> { val hours = time.toDouble(DurationUnit.HOURS).round(decimalPlaces) res.getQuantityString(R.plurals.duration_hours, hours.roundToInt(), hours.format()) } time.inWholeMinutes > 0 -> { val minutes = time.toDouble(DurationUnit.MINUTES).round(decimalPlaces) res.getQuantityString(R.plurals.duration_minutes, minutes.roundToInt(), minutes.format()) } else -> { res.getQuantityString(R.plurals.duration_seconds, timeSeconds.toInt(), timeSeconds) } } } @ExperimentalTime private val Duration.inWholeMonths get() = inWholeDays / 30 @ExperimentalTime private val Duration.inWholeYears get() = inWholeDays / 365 @ExperimentalTime private fun monthsToDuration(value: Long) = (value * 30).days @ExperimentalTime private fun yearsToDuration(value: Long) = (value * 365).days @ExperimentalTime private fun Duration.toMonthsDouble() = this.toDouble(DurationUnit.DAYS) / 30.0 @ExperimentalTime private fun Duration.toYearsDouble() = this.toDouble(DurationUnit.DAYS) / 365.0 private fun Double.round(decimalPlaces: Int) = this.toBigDecimal() .setScale(decimalPlaces, RoundingMode.HALF_UP) .toDouble() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/extensions/ActivityExtensions.kt ================================================ package io.github.drumber.kitsune.util.extensions import android.app.Activity import android.content.Context import android.content.res.Configuration import android.content.res.Resources import android.util.TypedValue import android.widget.Toast import androidx.annotation.ColorInt import androidx.annotation.ColorRes import androidx.core.content.ContextCompat import androidx.core.view.WindowInsetsControllerCompat import io.github.drumber.kitsune.R fun Activity.setStatusBarColor(@ColorInt color: Int) { if (window.statusBarColor != color) window.statusBarColor = color } fun Activity.setStatusBarColorRes(@ColorRes colorResource: Int) { setStatusBarColor(ContextCompat.getColor(this, colorResource)) } fun Activity.setLightStatusBar() { WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = true } fun Activity.clearLightStatusBar() { WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars = false } fun Activity.isLightStatusBar(): Boolean { return WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightStatusBars } fun Activity.clearLightNavigationBar() { WindowInsetsControllerCompat(window, window.decorView).isAppearanceLightNavigationBars = false } fun Context.isNightMode(): Boolean { return (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES } fun Resources.Theme.getColor(resid: Int): Int { val typedValue = TypedValue() this.resolveAttribute(resid, typedValue, true) return typedValue.data } fun Resources.Theme.getResourceId(resid: Int): Int { val typedValue = TypedValue() this.resolveAttribute(resid, typedValue, true) return typedValue.resourceId } fun Context.showSomethingWrongToast() { Toast.makeText(this, R.string.error_something_wrong, Toast.LENGTH_SHORT).show() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/extensions/FragmentExtensions.kt ================================================ package io.github.drumber.kitsune.util.extensions import android.content.Intent import android.net.Uri import android.view.View import androidx.annotation.IdRes import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.navigation.ActivityNavigatorExtras import androidx.navigation.NavController import androidx.navigation.NavDirections import androidx.navigation.NavOptions import androidx.navigation.Navigator import androidx.navigation.fragment.findNavController import io.github.drumber.kitsune.ui.photoview.PhotoViewActivityDirections import io.github.drumber.kitsune.util.logE /** * Checks if the current destination of the back stack is equal to the specified destination id. * This avoids simultaneous navigation calls, e.g. when the user clicks on two list items at the same time. */ fun NavController.navigateSafe( @IdRes currentNavId: Int, directions: NavDirections, navOptions: NavOptions? = null ) { if (this.currentDestination?.id == currentNavId) { this.navigate(directions, navOptions) } } /** * Checks if the current destination of the back stack is equal to the specified destination id. * This avoids simultaneous navigation calls, e.g. when the user clicks on two list items at the same time. */ fun NavController.navigateSafe( @IdRes currentNavId: Int, directions: NavDirections, navigationExtras: Navigator.Extras ) { if (this.currentDestination?.id == currentNavId) { this.navigate(directions, navigationExtras) } } fun Fragment.showSomethingWrongToast() { requireContext().showSomethingWrongToast() } fun Fragment.startUrlShareIntent(url: String, title: String? = null) { val shareIntent = Intent.createChooser(Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_TEXT, url) type = "text/plain" }, title) startActivity(shareIntent) } fun Fragment.openUrl(url: String) { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) try { startActivity(intent) } catch (e: Exception) { logE("Failed to open URL: $url", e) } } fun Fragment.openCharacterOnMAL(malId: Int) { val malCharacterUrl = "https://myanimelist.net/character/$malId" openUrl(malCharacterUrl) } fun Fragment.copyToClipboard(label: String, text: String) { context?.copyToClipboard(label, text) } fun Fragment.openPhotoViewActivity( imageUrl: String, title: String? = null, thumbnailUrl: String? = null, sharedElement: View? = null ) { val transitionName = sharedElement?.let { ViewCompat.getTransitionName(it) } val action = PhotoViewActivityDirections.actionGlobalPhotoViewActivity( imageUrl, title, thumbnailUrl, transitionName ) val options = if (sharedElement != null && transitionName != null) { ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), sharedElement, transitionName ) } else { null } val extras = ActivityNavigatorExtras(options) findNavController().navigate(action, extras) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/extensions/OtherExtensions.kt ================================================ package io.github.drumber.kitsune.util.extensions import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.res.Resources import androidx.core.view.get import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.elevation.ElevationOverlayProvider import io.github.drumber.kitsune.R import java.text.NumberFormat /** * Make the internal RecyclerView of ViewPager2 accessible. */ val ViewPager2.recyclerView: RecyclerView get() = this[0] as RecyclerView fun SwipeRefreshLayout.setAppTheme() { setProgressBackgroundColorSchemeColor( ElevationOverlayProvider(context).compositeOverlayWithThemeSurfaceColorIfNeeded(8.0f) ) setColorSchemeColors(context.theme.getColor(R.attr.colorPrimary)) } fun Context.copyToClipboard(label: String, text: String) { val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager val clip = ClipData.newPlainText(label, text) clipboard?.setPrimaryClip(clip) } /** * Format double using default locale format. */ fun Double.format(): String = NumberFormat.getInstance().format(this) fun Int.toDp() = (this / Resources.getSystem().displayMetrics.density).toInt() fun Int.toPx() = (this * Resources.getSystem().displayMetrics.density).toInt() fun Float.toPx() = this * Resources.getSystem().displayMetrics.density ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/json/AlgoliaFacetValueDeserializer.kt ================================================ package io.github.drumber.kitsune.util.json import com.algolia.search.model.filter.Filter.Facet.Value import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer import com.fasterxml.jackson.databind.node.JsonNodeType /** * Custom jackson deserializer for [com.algolia.search.model.filter.Filter.Facet.Value]. */ class AlgoliaFacetValueDeserializer @JvmOverloads constructor(vc: Class<*>? = null) : StdDeserializer(vc) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Value { val node = p.codec.readTree(p) return when (node.get("raw").nodeType) { JsonNodeType.STRING -> p.codec.treeToValue(node, Value.String::class.java) JsonNodeType.BOOLEAN -> p.codec.treeToValue(node, Value.Boolean::class.java) JsonNodeType.NUMBER -> p.codec.treeToValue(node, Value.Number::class.java) else -> throw JsonParseException( p, "Unsupported type of com.algolia.search.model.filter.Filter.Facet.Value 'raw' field." ) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/json/AlgoliaNumericValueDeserializer.kt ================================================ package io.github.drumber.kitsune.util.json import com.algolia.search.model.filter.Filter.Numeric.Value import com.fasterxml.jackson.core.JsonParseException import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.deser.std.StdDeserializer /** * Custom jackson deserializer for [com.algolia.search.model.filter.Filter.Numeric.Value]. */ class AlgoliaNumericValueDeserializer @JvmOverloads constructor(vc: Class<*>? = null) : StdDeserializer(vc) { override fun deserialize(p: JsonParser, ctxt: DeserializationContext): Value { val node = p.codec.readTree(p) return when { isComparison(node) -> p.codec.treeToValue(node, Value.Comparison::class.java) isRange(node) -> p.codec.treeToValue(node, Value.Range::class.java) else -> throw JsonParseException( p, "Unsupported type of com.algolia.search.model.filter.Filter.Numeric.Value 'raw' field." ) } } private fun isComparison(node: JsonNode): Boolean { return node.has("operator") && node.has("number") } private fun isRange(node: JsonNode): Boolean { return node.has("lowerBound") && node.has("upperBound") } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/json/IgnoreParcelablePropertyMixin.kt ================================================ package io.github.drumber.kitsune.util.json import com.fasterxml.jackson.annotation.JsonIgnore abstract class IgnoreParcelablePropertyMixin { @JsonIgnore abstract fun getStability(): Int } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/json/NullableIntSerializer.kt ================================================ package io.github.drumber.kitsune.util.json import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.SerializerProvider /** * [JsonSerializer] that serializes `-1` to `null`. */ class NullableIntSerializer : JsonSerializer() { override fun serialize(value: Int?, gen: JsonGenerator, serializers: SerializerProvider) { if (value != null && value == -1) { gen.writeNull() } else if (value != null) { gen.writeNumber(value) } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/network/AuthenticationInterceptor.kt ================================================ package io.github.drumber.kitsune.util.network import io.github.drumber.kitsune.constants.Kitsu.API_HOST import io.github.drumber.kitsune.data.repository.AccessTokenRepository import io.github.drumber.kitsune.domain.auth.RefreshAccessTokenUseCase import io.github.drumber.kitsune.domain.auth.RefreshResult import io.github.drumber.kitsune.util.logD import io.github.drumber.kitsune.util.logI import kotlinx.coroutines.runBlocking import okhttp3.Authenticator import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response import okhttp3.Route import org.koin.core.component.KoinComponent import org.koin.core.component.get interface AuthenticationInterceptor : Interceptor, Authenticator class AuthenticationInterceptorImpl( private val accessTokenRepository: AccessTokenRepository ) : AuthenticationInterceptor, KoinComponent { override fun intercept(chain: Interceptor.Chain): Response { val requestBuilder = chain.request().newBuilder() if (chain.request().url.host == API_HOST) { accessTokenRepository.getAccessToken()?.accessToken?.let { requestBuilder.header("Authorization", "Bearer $it") } } return chain.proceed(requestBuilder.build()) } /** * This method is automatically called by retrofit when a request fails with a 401 code. */ override fun authenticate(route: Route?, response: Response): Request? { if (response.request.url.host != API_HOST || response.responseCount > 3) return null val localAccessTokenHolder = accessTokenRepository.getAccessToken() val localAccessToken = localAccessTokenHolder?.accessToken if (!accessTokenRepository.hasAccessToken() || localAccessToken == null) return null // check if the token from the request differs from the local stored access token val isTokenAlreadyRefreshed = response.request .header("Authorization") ?.endsWith(localAccessToken) == false val accessToken = if (isTokenAlreadyRefreshed) { logD("Local access token was changed during this request. Do not refresh access token and retry with changed access token.") localAccessToken } else { logI("Refreshing access token because of a 401 Unauthorized response.") val refreshResult = runBlocking { val refreshAccessToken: RefreshAccessTokenUseCase = get() refreshAccessToken() } if (refreshResult !is RefreshResult.Success) return null refreshResult.accessToken.accessToken } return response.request.newBuilder() .header("Authorization", "Bearer $accessToken") .build() } private val Response.responseCount: Int get() = generateSequence(this) { it.priorResponse }.count() } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/network/ResponseData.kt ================================================ package io.github.drumber.kitsune.util.network sealed class ResponseData(open val data: T?) { data class Success(override val data: T): ResponseData(data) data class Error(val e: Exception, override val data: T? = null): ResponseData(data) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/network/UserAgentInterceptor.kt ================================================ package io.github.drumber.kitsune.util.network import okhttp3.Interceptor import okhttp3.Response class UserAgentInterceptor(private val userAgent: String) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request().newBuilder() .header("User-Agent", userAgent) .build() return chain.proceed(request) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/rating/RatingFrequenciesUtil.kt ================================================ package io.github.drumber.kitsune.util.rating import io.github.drumber.kitsune.data.common.media.RatingFrequencies import io.github.drumber.kitsune.data.source.local.user.model.LocalRatingSystemPreference import io.github.drumber.kitsune.util.rating.RatingSystemUtil.convertFrom object RatingFrequenciesUtil { fun RatingFrequencies.transformToRatingSystem(ratingSystem: LocalRatingSystemPreference): List { val ratingCounts = this.toList().map { it?.toIntOrNull() ?: 0 } // contains the rating counts categorized by the rating type (1-4 for simple, 0.5-5 for regular, 1-10 for advanced) val ratingsMap = mutableMapOf() ratingCounts.forEachIndexed { index, value -> val ratingValue = ratingSystem.convertFrom(index + 2).toString() val prevValue = ratingsMap[ratingValue] if (prevValue != null) { ratingsMap[ratingValue] = prevValue + value } else { ratingsMap[ratingValue] = value } } return ratingsMap.values.toList() } fun RatingFrequencies.calculateAverageRating(ratingSystem: LocalRatingSystemPreference): Double { val ratingCounts = this.toList().map { it?.toIntOrNull() ?: 0 } if (ratingCounts.isEmpty()) return 0.0 var sum = 0.0 var totalRatings = 0 for (i in ratingCounts.indices) { val count = ratingCounts[i] sum += count * ratingSystem.convertFrom(i + 2) totalRatings += count } if (totalRatings == 0) return 0.0 return sum / totalRatings } fun RatingFrequencies.toList() = listOf( r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12, r13, r14, r15, r16, r17, r18, r19, r20 ) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/rating/RatingSystemUtil.kt ================================================ package io.github.drumber.kitsune.util.rating import io.github.drumber.kitsune.data.repository.UserRepository import io.github.drumber.kitsune.data.source.local.user.model.LocalRatingSystemPreference import org.koin.core.component.KoinComponent import org.koin.core.component.inject import kotlin.math.floor object RatingSystemUtil : KoinComponent { private val DEFAULT = LocalRatingSystemPreference.Regular private val userRepository: UserRepository by inject() fun getRatingSystem(): LocalRatingSystemPreference { return userRepository.localUser.value?.ratingSystem ?: DEFAULT } fun formatRating(ratingTwenty: Int, ratingSystem: LocalRatingSystemPreference = getRatingSystem()): String { return ratingSystem.convertFrom(ratingTwenty).toString() } fun Int.formatRatingTwenty() = formatRating(this) fun Int.fromRatingTwentyTo(ratingSystem: LocalRatingSystemPreference = getRatingSystem()) = ratingSystem.convertFrom(this) fun Float.toRatingTwentyFrom(ratingSystem: LocalRatingSystemPreference = getRatingSystem()) = ratingSystem.convertToRatingTwenty(this) fun LocalRatingSystemPreference.convertFrom(ratingTwenty: Int): Float { return when (this) { LocalRatingSystemPreference.Simple -> when (ratingTwenty) { in 1..7 -> 1f in 8..13 -> 2f in 14..19 -> 3f else -> 4f } LocalRatingSystemPreference.Regular -> floor(ratingTwenty / 2.0f) / 2.0f LocalRatingSystemPreference.Advanced -> ratingTwenty / 2.0f } } fun LocalRatingSystemPreference.convertToRatingTwenty(rating: Float): Int { return when (this) { LocalRatingSystemPreference.Simple -> when (rating) { 1f -> 2 2f -> 8 3f -> 14 else -> 20 } LocalRatingSystemPreference.Regular -> floor(rating * 4).toInt() LocalRatingSystemPreference.Advanced -> floor(rating * 2).toInt() } } fun LocalRatingSystemPreference.stepSize(): Float { return when (this) { LocalRatingSystemPreference.Simple -> 1f LocalRatingSystemPreference.Regular -> 0.5f LocalRatingSystemPreference.Advanced -> 0.5f } } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/ui/BindingAdapter.kt ================================================ package io.github.drumber.kitsune.util.ui import android.content.Intent import android.net.Uri import android.view.View import android.widget.Button import android.widget.ImageView import android.widget.TextView import androidx.appcompat.widget.TooltipCompat import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.databinding.BindingAdapter import at.blogc.android.views.ExpandableTextView import com.bumptech.glide.Glide import com.google.android.material.button.MaterialButton import io.github.drumber.kitsune.R object BindingAdapter { @JvmStatic @BindingAdapter("actionButton") fun setExpandCollapseButton(expandableTextView: ExpandableTextView, actionView: TextView) { expandableTextView.addOnExpandListener(object : ExpandableTextView.OnExpandListener { override fun onExpand(view: ExpandableTextView) { actionView.setText(R.string.action_read_less) } override fun onCollapse(view: ExpandableTextView) { actionView.setText(R.string.action_read_more) } }) expandableTextView.post { actionView.isVisible = expandableTextView.lineCount >= expandableTextView.maxLines } expandableTextView.doOnTextChanged { _, _, _, _ -> expandableTextView.post { actionView.isVisible = expandableTextView.lineCount >= expandableTextView.maxLines } } actionView.setOnClickListener { expandableTextView.toggle() } } @JvmStatic @BindingAdapter("isVisible") fun isVisible(view: View, isVisible: Boolean) { view.isVisible = isVisible } @JvmStatic @BindingAdapter("imageUrl") fun loadGlideImage(view: ImageView, url: String?) { Glide.with(view) .load(url) .placeholder(R.drawable.ic_insert_photo_48) .into(view) } @JvmStatic @BindingAdapter("openOnClick") fun openUrl(view: View, url: String?) { if (url.isNullOrBlank()) return view.setOnClickListener { val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) it.context.startActivity(intent) } } @JvmStatic @BindingAdapter("activated") fun isActivated(button: Button, isActivated: Boolean) { button.isActivated = isActivated } @JvmStatic @BindingAdapter("tooltip") fun tooltip(view: View, text: String) { TooltipCompat.setTooltipText(view, text) } @JvmStatic @BindingAdapter("iconPadding") fun iconPadding(button: MaterialButton, padding: Float) { button.iconPadding = padding.toInt() } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/ui/DateValidatorPointBetween.kt ================================================ package io.github.drumber.kitsune.util.ui import com.google.android.material.datepicker.CalendarConstraints import com.google.android.material.datepicker.DateValidatorPointBackward import com.google.android.material.datepicker.DateValidatorPointForward import kotlinx.parcelize.Parcelize @Parcelize data class DateValidatorPointBetween( private val pointBackward: DateValidatorPointBackward, private val pointForward: DateValidatorPointForward ) : CalendarConstraints.DateValidator { companion object { fun between(from: Long, before: Long) = DateValidatorPointBetween( DateValidatorPointBackward.before(before), DateValidatorPointForward.from(from) ) fun nowAndFrom(from: Long) = DateValidatorPointBetween( DateValidatorPointBackward.now(), DateValidatorPointForward.from(from) ) } override fun isValid(date: Long): Boolean { return pointBackward.isValid(date) && pointForward.isValid(date) } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/ui/ProfileSiteLogo.kt ================================================ package io.github.drumber.kitsune.util.ui import io.github.drumber.kitsune.R fun getProfileSiteLogoResourceId(name: String?): Int { return when (name) { "Twitter" -> R.drawable.ic_twitter "Facebook" -> R.drawable.ic_facebook "YouTube" -> R.drawable.ic_youtube "Google" -> R.drawable.ic_google_plus "Instagram" -> R.drawable.ic_instagram "Twitch" -> R.drawable.ic_twitch "Vimeo" -> R.drawable.ic_vimeo "GitHub" -> R.drawable.ic_github "Battle.net" -> R.drawable.ic_battle_net "Steam" -> R.drawable.ic_steam "Raptr" -> R.drawable.ic_raptr "Discord" -> R.drawable.ic_discord "Tumblr" -> R.drawable.ic_tumblr "SoundCloud" -> R.drawable.ic_soundcloud "Dailymotion" -> R.drawable.ic_dailymotion "Kickstarter" -> R.drawable.ic_kickstarter "Mobcrush" -> R.drawable.ic_mobcrush "osu!" -> R.drawable.ic_osu "Patreon" -> R.drawable.ic_patreon "DeviantArt" -> R.drawable.ic_deviantart "Dribbble" -> R.drawable.ic_dribbble "IMDb" -> R.drawable.ic_imdb "Last.fm" -> R.drawable.ic_lastfm "Letterboxd" -> R.drawable.ic_letterboxd "Medium" -> R.drawable.ic_medium "Player.me" -> R.drawable.ic_player_me "Reddit" -> R.drawable.ic_reddit "Trakt" -> R.drawable.ic_trakt "Website" -> R.drawable.ic_website else -> R.drawable.ic_website } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/ui/RoundBitmapDrawable.kt ================================================ package io.github.drumber.kitsune.util.ui import android.content.res.ColorStateList import android.graphics.Bitmap import android.graphics.BitmapShader import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.RectF import android.graphics.Shader import android.graphics.drawable.Drawable import android.media.ThumbnailUtils import io.github.drumber.kitsune.util.extensions.toPx import kotlin.math.min class RoundBitmapDrawable(private val bitmap: Bitmap) : Drawable() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG) private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) private val rect = RectF() private var scaledBitmap: Bitmap? = null private var tint: ColorStateList? = null private val borderWidth = 1f.toPx() private var isSelected = false override fun draw(canvas: Canvas) { val bounds = bounds val width = bounds.width() val height = bounds.height() // use the smaller dimension as the circle size val size = min(width, height) // calculate the rectangle to center the bitmap val left = (width - size) / 2f val top = (height - size) / 2f val right = left + size val bottom = top + size rect.set(left, top, right, bottom) val tint = this.tint if (tint != null) { paint.color = tint.defaultColor } else { paint.color = Color.TRANSPARENT } // draw circular background paint.shader = null canvas.drawCircle(rect.centerX(), rect.centerY(), size / 2f, paint) // draw the circular bitmap val bitmap = scaledBitmap ?: updateScaledBitmap(bounds) val bitmapShader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) paint.shader = bitmapShader canvas.drawCircle(rect.centerX(), rect.centerY(), size / 2f, paint) if (isSelected && tint != null) { // draw circular border when selected borderPaint.color = tint.getColorForState(state, tint.defaultColor) borderPaint.strokeWidth = borderWidth borderPaint.style = Paint.Style.STROKE val strokeRadius = (size - borderWidth) / 2f canvas.drawCircle(rect.centerX(), rect.centerY(), strokeRadius, borderPaint) } } override fun setTintList(tint: ColorStateList?) { if (this.tint != tint) { this.tint = tint } } override fun setAlpha(alpha: Int) { paint.alpha = alpha borderPaint.alpha = alpha } override fun setColorFilter(colorFilter: ColorFilter?) {} @Deprecated("Deprecated in Java") override fun getOpacity(): Int { return PixelFormat.TRANSLUCENT } override fun onBoundsChange(bounds: Rect) { super.onBoundsChange(bounds) updateScaledBitmap(bounds) } override fun invalidateSelf() { super.invalidateSelf() scaledBitmap?.recycle() scaledBitmap = null } override fun onStateChange(state: IntArray): Boolean { val tint = tint ?: return false val stateColor = tint.getColorForState(state, tint.defaultColor) val isSelected = stateColor != tint.defaultColor && state.any { it == android.R.attr.state_selected } if (this.isSelected != isSelected) { this.isSelected = isSelected invalidateSelf() return true } return false } override fun isStateful(): Boolean { return tint != null } private fun updateScaledBitmap(bounds: Rect): Bitmap { scaledBitmap?.recycle() scaledBitmap = null // use the smaller dimension as the circle size val size = min(bounds.width(), bounds.height()) val scaledBitmap = ThumbnailUtils.extractThumbnail(bitmap, size, size) this.scaledBitmap = scaledBitmap return scaledBitmap } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/ui/SnackbarUtils.kt ================================================ package io.github.drumber.kitsune.util.ui import android.view.View import androidx.annotation.StringRes import com.google.android.material.snackbar.Snackbar import io.github.drumber.kitsune.R import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateFailureReason.NotFound import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult.Failure import io.github.drumber.kitsune.domain.library.LibraryEntryUpdateResult.Success fun showSnackbar( parent: View, message: CharSequence, duration: Int = Snackbar.LENGTH_LONG ): Snackbar { return Snackbar.make(parent, message, duration).apply { // fixes unnecessary bottom margin view.initMarginWindowInsetsListener(left = true, right = true) show() } } fun showSnackbar( parent: View, @StringRes stringRes: Int, duration: Int = Snackbar.LENGTH_LONG ): Snackbar { return showSnackbar(parent, parent.resources.getText(stringRes), duration) } fun LibraryEntryUpdateResult.showSnackbarOnFailure(parent: View): Snackbar? { val stringRes = when (this) { is Success -> return null is Failure -> when (reason) { NotFound -> R.string.error_library_update_not_found else -> R.string.error_library_update_failed } } return showSnackbar(parent, stringRes) } fun List.showSnackbarOnAnyFailure(parent: View): Snackbar? { val failedCount = count { it !is Success } if (failedCount == 0) return null val stringRes = when (failedCount) { 1 -> R.string.error_library_update_failed else -> R.string.error_library_update_failed_multiple } return showSnackbar(parent, parent.resources.getString(stringRes, failedCount)) } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/util/ui/WindowInsetsUtil.kt ================================================ package io.github.drumber.kitsune.util.ui import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.Toolbar import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.marginBottom import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import com.google.android.material.appbar.CollapsingToolbarLayout fun Toolbar.initWindowInsetsListener(consume: Boolean = true) { val initialHeight = this.layoutParams.height ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getSystemBarsAndCutoutInsets() view.updatePadding( top = insets.top, left = insets.left, right = insets.right ) view.layoutParams.height = initialHeight + insets.top if (consume) WindowInsetsCompat.CONSUMED else windowInsets } } fun CollapsingToolbarLayout.initWindowInsetsListener(consume: Boolean = true) { val initialHeight = this.layoutParams.height val defaultTitleMarginStart = this.expandedTitleMarginStart val defaultTitleMarginEnd = this.expandedTitleMarginStart val defaultScrimVisibleHeightTrigger = this.scrimVisibleHeightTrigger ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getSystemBarsAndCutoutInsets() view.layoutParams.height = initialHeight + insets.top this.scrimVisibleHeightTrigger = defaultScrimVisibleHeightTrigger + insets.top val isRtl = ViewCompat.getLayoutDirection(view) == ViewCompat.LAYOUT_DIRECTION_RTL this.expandedTitleMarginStart = defaultTitleMarginStart + if (isRtl) insets.right else insets.left this.expandedTitleMarginEnd = defaultTitleMarginEnd + if (isRtl) insets.left else insets.right if (consume) WindowInsetsCompat.CONSUMED else windowInsets } } fun View.initPaddingWindowInsetsListener( left: Boolean = false, top: Boolean = false, right: Boolean = false, bottom: Boolean = false, consume: Boolean = true ) { val (initialLeft, initialTop, initialRight, initialBottom) = listOf( paddingLeft, paddingTop, paddingRight, paddingBottom ) ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getSystemBarsAndCutoutInsets() view.updatePadding( left = if (left) insets.left + initialLeft else paddingLeft, top = if (top) insets.top + initialTop else paddingTop, right = if (right) insets.right + initialRight else paddingRight, bottom = if (bottom) insets.bottom + initialBottom else paddingBottom ) if (consume) WindowInsetsCompat.CONSUMED else windowInsets } } fun View.initMarginWindowInsetsListener( left: Boolean = false, top: Boolean = false, right: Boolean = false, bottom: Boolean = false, consume: Boolean = true ) { val (initialLeft, initialTop, initialRight, initialBottom) = listOf( marginLeft, marginTop, marginRight, marginBottom ) ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getSystemBarsAndCutoutInsets() view.updateLayoutParams { if (left) leftMargin = insets.left + initialLeft if (top) topMargin = insets.top + initialTop if (right) rightMargin = insets.right + initialRight if (bottom) bottomMargin = insets.bottom + initialBottom } if (consume) WindowInsetsCompat.CONSUMED else windowInsets } } fun View.initImePaddingWindowInsetsListener(subtractSystemBarInset: Boolean = true) { val initialPaddingBottom = paddingBottom ViewCompat.setOnApplyWindowInsetsListener(this) { view, insets -> val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) val imeHeight = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom val paddingBottom = if (subtractSystemBarInset) { val systemBarsBottom = insets.getSystemBarsAndCutoutInsets().bottom imeHeight - systemBarsBottom } else { imeHeight } if (imeVisible) { view.updatePadding(bottom = initialPaddingBottom + paddingBottom) } else { view.updatePadding(bottom = initialPaddingBottom) } insets } } fun View.initHeightWindowInsetsListener( useBottomInset: Boolean = false, consume: Boolean = true ) { val initialHeight = height ViewCompat.setOnApplyWindowInsetsListener(this) { view, windowInsets -> val insets = windowInsets.getSystemBarsAndCutoutInsets() view.updateLayoutParams { height = initialHeight + if (useBottomInset) insets.bottom else insets.top } if (consume) WindowInsetsCompat.CONSUMED else windowInsets } } fun WindowInsetsCompat.getSystemBarsAndCutoutInsets() = getInsets( WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() ) ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/work/SyncLibraryEntriesForWidgetWorker.kt ================================================ package io.github.drumber.kitsune.work import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import io.github.drumber.kitsune.constants.LibraryWidget import io.github.drumber.kitsune.domain.library.FetchLibraryEntriesForWidgetUseCase import io.github.drumber.kitsune.domain.library.SynchronizeLocalLibraryModificationsUseCase import io.github.drumber.kitsune.preference.KitsunePref import org.koin.core.component.KoinComponent import org.koin.core.component.inject class SyncLibraryEntriesForWidgetWorker( context: Context, params: WorkerParameters ) : CoroutineWorker(context, params), KoinComponent { private val synchronizeLocalLibraryModifications: SynchronizeLocalLibraryModificationsUseCase by inject() private val fetchLibraryEntriesForWidget: FetchLibraryEntriesForWidgetUseCase by inject() override suspend fun doWork(): Result { synchronizeLocalLibraryModifications() fetchLibraryEntriesForWidget(LibraryWidget.MAX_ITEM_COUNT) KitsunePref.lastLibraryFetchForWidget = System.currentTimeMillis() return Result.success() } companion object { const val TAG = "syncLibraryEntriesForWidget" } } ================================================ FILE: app/src/main/java/io/github/drumber/kitsune/work/UpdateLibraryWidgetWorker.kt ================================================ package io.github.drumber.kitsune.work import android.content.Context import androidx.glance.appwidget.updateAll import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import io.github.drumber.kitsune.ui.widget.LibraryAppWidget class UpdateLibraryWidgetWorker( private val context: Context, params: WorkerParameters ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { LibraryAppWidget().updateAll(context) return Result.success() } companion object { const val TAG = "updateLibraryWidget" } } ================================================ FILE: app/src/main/res/anim/slide_down.xml ================================================ ================================================ FILE: app/src/main/res/anim/slide_up.xml ================================================ ================================================ FILE: app/src/main/res/animator/scale_enter_anim.xml ================================================ ================================================ FILE: app/src/main/res/animator/scale_exit_anim.xml ================================================ ================================================ FILE: app/src/main/res/animator/scale_pop_enter_anim.xml ================================================ ================================================ FILE: app/src/main/res/animator/scale_pop_exit_anim.xml ================================================ ================================================ FILE: app/src/main/res/color/subtype_badge_background.xml ================================================ ================================================ FILE: app/src/main/res/color/translucent_overlay.xml ================================================ ================================================ FILE: app/src/main/res/color/translucent_status_bar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/animated_favorite.xml ================================================ ================================================ FILE: app/src/main/res/drawable/badge_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bottom_edge_fade.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bottom_edge_fade_surface.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cover_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/explore_section_divider.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add_a_photo_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_amazon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_animelab.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_back_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_drop_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_forward_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bar_chart_16.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_battle_net.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark_added_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cake_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_calendar_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_calendar_month_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cancel_presentation_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_check_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_close_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloud_off_16.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_code_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_contv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_crunchyroll.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_dailymotion.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_delete_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_deviantart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_discord.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_done_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_dribbble.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_edit_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_emoji_events_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_facebook.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_favorite_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_favorite_border_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_filter_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_funimation.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_github.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_google_plus.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_heart_broken_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_hidive.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_hulu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_imdb.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_incomplete_circle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_insert_photo_48.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_instagram.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_keyboard_arrow_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_kickstarter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_lastfm.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_letterboxd.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_location_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_medium.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_mobcrush.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_more_vert.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_netflix.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notification_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_open_in_browser_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_open_in_new_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_osu.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_bookmarks_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_explore_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_info_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_person_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_view_list_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_patreon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_person_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_player_me.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_raptr.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_reddit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_remove_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_restore_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_save_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_save_alt_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_schedule_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_share_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shortcut_library_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shortcut_search_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shortcut_settings_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_soundcloud.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_splashscreen.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_star_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_star_outline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_steam.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sync_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_trakt.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_tubitv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_tumblr.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_twitch.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_twitter.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_view_list_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_vimeo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_vrv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_watch_later_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_website.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_youtube.xml ================================================ ================================================ FILE: app/src/main/res/drawable/onboarding_login_logo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/profile_picture_placeholder.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progress_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/radial_edge_fade.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rectangle_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selectable_item_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_home.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_library.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_profile.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/top_edge_fade.xml ================================================ ================================================ FILE: app/src/main/res/drawable/top_edge_fade_surface.xml ================================================ ================================================ FILE: app/src/main/res/drawable/translucent_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/widget_rounded_rect.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/progress_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_authentication.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_photo_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/custom_edit_text_preference.xml ================================================ ================================================ FILE: app/src/main/res/layout/custom_number_spinner.xml ================================================ ================================================ FILE: app/src/main/res/layout/custom_preference_switch.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_app_logs.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_categories.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_characters.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_details.xml ================================================