From 2ca9951e71e719ab1fe7c94ba8fa910916df800e Mon Sep 17 00:00:00 2001 From: Alexey Illarionov Date: Sun, 28 Jan 2024 18:55:15 +0300 Subject: [PATCH] WIP Add sync (#259) Sync., emdiator WIP --- .../ui/CalendarScreenFeedPaddingsTest.kt | 2 +- ...ockObserveUpcomingReleasesByDateUseCase.kt | 2 +- config/diktat.yml | 2 +- .../feature/calendar/data/IgdbDataSource.kt | 7 + .../ObserveUpcomingReleasesByDateUseCase.kt | 7 + .../data/domain/upcoming/UpcomingRelease.kt | 14 -- .../calendar/data/model/GameModeIgdbDto.kt | 15 ++ .../calendar/data/calendar-data.gradle.kts | 3 + .../calendar/data/DefaultRemoteMediator.kt | 75 ++++++++ .../status/DefaultIgdbSyncStatusRepository.kt | 35 ++++ .../status/IgdbSyncStatusRepository.kt | 16 ++ .../DefaultUpcomingReleaseRepository.kt} | 24 ++- .../upcoming/IgdbPagingSourceKey.kt | 11 ++ .../upcoming/UpcomingReleaseRepository.kt | 16 ++ .../upcoming/UpcomingReleasesPagingSource.kt | 11 ++ .../data/sync/IgdbGameModeSyncService.kt | 179 ++++++++++++++++++ .../feature/calendar/data/sync/SyncService.kt | 100 ++++++++++ .../calendar/data/sync/policy/SyncPolicy.kt | 12 ++ .../data/sync/policy/SyncPolicyExt.kt | 40 ++++ ...ultObserveUpcomingReleasesByDateUseCase.kt | 25 ++- .../domain/upcoming/IgdbPagingSource.kt | 26 --- .../DefaultUpcomingReleaseRepositoryTest.kt} | 37 ++-- .../sync/policy/SyncPolicyEvaluatorTest.kt | 52 +++++ .../datasource/igdb/DefaultIgdbDataSource.kt | 61 +++++- .../game/IgdbGameGameModeConverter.kt | 36 +++- .../game/IgdbGameGameModeConverterTest.kt | 19 +- .../feature/calendar/CalendarViewModel.kt | 2 +- .../converter/UpcomingGameListConverter.kt | 2 +- .../feature/calendar/CalendarViewModelTest.kt | 2 +- .../converter/ListOrderTestExpectedItem.kt | 2 +- .../UpcomingGameListConverterTest.kt | 2 +- .../fixture/UpcomingReleasesFixtures.kt | 2 +- .../foundation/appconfig/AppConfigExt.kt | 9 + foundation/database/database.gradle.kts | 1 + .../1.json | 30 ++- .../foundation/database/PixnewsDatabase.kt | 8 + .../foundation/database/dao/GameModeDao.kt | 38 ++++ .../database/dao/GameModeNameDao.kt | 70 +++++++ .../database/dao/IgdbSyncStatusDao.kt | 20 ++ .../entity/sync/IgdbSyncStatusEntity.kt | 18 ++ .../database/inject/DatabaseModule.kt | 17 +- .../foundation/database/util/QueryLogger.kt | 31 +++ .../ru/pixnews/domain/model/game/GameMode.kt | 12 +- .../ru/pixnews/domain/model/id/GameModeId.kt | 4 +- 44 files changed, 994 insertions(+), 103 deletions(-) delete mode 100644 feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/UpcomingRelease.kt create mode 100644 feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/model/GameModeIgdbDto.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/DefaultRemoteMediator.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/DefaultIgdbSyncStatusRepository.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/IgdbSyncStatusRepository.kt rename feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/{DefaultUpcomingReleasePagingSourceFactory.kt => repository/upcoming/DefaultUpcomingReleaseRepository.kt} (76%) create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/IgdbPagingSourceKey.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleaseRepository.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleasesPagingSource.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/IgdbGameModeSyncService.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/SyncService.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicy.kt create mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyExt.kt delete mode 100644 feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/IgdbPagingSource.kt rename feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/{UpcomingReleasePagingSourceFactoryTest.kt => repository/upcoming/DefaultUpcomingReleaseRepositoryTest.kt} (86%) create mode 100644 feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyEvaluatorTest.kt create mode 100644 foundation/appconfig/main/ru/pixnews/foundation/appconfig/AppConfigExt.kt create mode 100644 foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeDao.kt create mode 100644 foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeNameDao.kt create mode 100644 foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/IgdbSyncStatusDao.kt create mode 100644 foundation/database/src/main/kotlin/ru/pixnews/foundation/database/entity/sync/IgdbSyncStatusEntity.kt create mode 100644 foundation/database/src/main/kotlin/ru/pixnews/foundation/database/util/QueryLogger.kt diff --git a/app/src/androidTest/kotlin/ru/pixnews/feature/calendar/ui/CalendarScreenFeedPaddingsTest.kt b/app/src/androidTest/kotlin/ru/pixnews/feature/calendar/ui/CalendarScreenFeedPaddingsTest.kt index baebe136..2c484c5d 100644 --- a/app/src/androidTest/kotlin/ru/pixnews/feature/calendar/ui/CalendarScreenFeedPaddingsTest.kt +++ b/app/src/androidTest/kotlin/ru/pixnews/feature/calendar/ui/CalendarScreenFeedPaddingsTest.kt @@ -24,7 +24,7 @@ import ru.pixnews.domain.model.game.game.beyondGoodEvil2 import ru.pixnews.domain.model.game.game.gta6 import ru.pixnews.domain.model.game.game.hytale import ru.pixnews.domain.model.game.game.sims5 -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.test.constants.UpcomingReleaseGroupId import ru.pixnews.feature.calendar.test.constants.toGroupId import ru.pixnews.feature.calendar.test.element.CalendarHeaderElement diff --git a/app/src/androidTest/kotlin/ru/pixnews/inject/data/MockObserveUpcomingReleasesByDateUseCase.kt b/app/src/androidTest/kotlin/ru/pixnews/inject/data/MockObserveUpcomingReleasesByDateUseCase.kt index c1c77c25..4510de7e 100644 --- a/app/src/androidTest/kotlin/ru/pixnews/inject/data/MockObserveUpcomingReleasesByDateUseCase.kt +++ b/app/src/androidTest/kotlin/ru/pixnews/inject/data/MockObserveUpcomingReleasesByDateUseCase.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flow import ru.pixnews.domain.model.game.GameField import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.domain.upcoming.DefaultObserveUpcomingReleasesByDateUseCase import ru.pixnews.foundation.di.base.scopes.AppScope import javax.inject.Inject diff --git a/config/diktat.yml b/config/diktat.yml index 1a25ccad..8800f1a7 100644 --- a/config/diktat.yml +++ b/config/diktat.yml @@ -12,7 +12,7 @@ - name: BACKTICKS_PROHIBITED enabled: true - ignoreAnnotated: [ Nested, ParameterizedTest ] + ignoreAnnotated: [ Nested, ParameterizedTest, TestFactory ] - name: COMMENTED_BY_KDOC enabled: false diff --git a/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/IgdbDataSource.kt b/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/IgdbDataSource.kt index 9534427a..33819e62 100644 --- a/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/IgdbDataSource.kt +++ b/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/IgdbDataSource.kt @@ -8,6 +8,7 @@ package ru.pixnews.feature.calendar.data import kotlinx.datetime.Instant import ru.pixnews.domain.model.game.Game import ru.pixnews.domain.model.game.GameField +import ru.pixnews.feature.calendar.data.model.GameModeIgdbDto import ru.pixnews.library.functional.network.NetworkResult public interface IgdbDataSource { @@ -17,4 +18,10 @@ public interface IgdbDataSource { offset: Int = 0, limit: Int = 100, ): NetworkResult> + + public suspend fun getGameModes( + updatedLaterThan: Instant?, + offset: Int = 0, + limit: Int = 100, + ): NetworkResult> } diff --git a/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/ObserveUpcomingReleasesByDateUseCase.kt b/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/ObserveUpcomingReleasesByDateUseCase.kt index 99111949..7d64300c 100644 --- a/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/ObserveUpcomingReleasesByDateUseCase.kt +++ b/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/ObserveUpcomingReleasesByDateUseCase.kt @@ -7,8 +7,15 @@ package ru.pixnews.feature.calendar.data.domain.upcoming import androidx.paging.PagingData import kotlinx.coroutines.flow.Flow +import ru.pixnews.domain.model.UpcomingReleaseTimeCategory +import ru.pixnews.domain.model.game.Game import ru.pixnews.domain.model.game.GameField public interface ObserveUpcomingReleasesByDateUseCase { public fun createUpcomingReleasesObservable(requiredFields: Set): Flow> + + public data class UpcomingRelease( + val game: Game, + val group: UpcomingReleaseTimeCategory, + ) } diff --git a/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/UpcomingRelease.kt b/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/UpcomingRelease.kt deleted file mode 100644 index a21c09e2..00000000 --- a/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/domain/upcoming/UpcomingRelease.kt +++ /dev/null @@ -1,14 +0,0 @@ -/* - * Copyright (c) 2023, the Pixnews project authors and contributors. Please see the AUTHORS file for details. - * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. - */ - -package ru.pixnews.feature.calendar.data.domain.upcoming - -import ru.pixnews.domain.model.UpcomingReleaseTimeCategory -import ru.pixnews.domain.model.game.Game - -public data class UpcomingRelease( - val game: Game, - val group: UpcomingReleaseTimeCategory, -) diff --git a/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/model/GameModeIgdbDto.kt b/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/model/GameModeIgdbDto.kt new file mode 100644 index 00000000..d1ac9a17 --- /dev/null +++ b/feature/calendar/data-public/main/ru/pixnews/feature/calendar/data/model/GameModeIgdbDto.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.model + +import kotlinx.datetime.Instant +import ru.pixnews.domain.model.game.GameMode + +public data class GameModeIgdbDto( + val mode: GameMode, + val igdbSlug: String, + val updatedAt: Instant, +) diff --git a/feature/calendar/data/calendar-data.gradle.kts b/feature/calendar/data/calendar-data.gradle.kts index fb0c25f0..609b9e87 100644 --- a/feature/calendar/data/calendar-data.gradle.kts +++ b/feature/calendar/data/calendar-data.gradle.kts @@ -18,11 +18,14 @@ android { dependencies { api(projects.feature.calendar.dataPublic) implementation(projects.foundation.appconfig) + implementation(projects.foundation.database) + implementation(projects.foundation.coroutines) implementation(projects.foundation.di.base) implementation(projects.foundation.domainModel) implementation(projects.foundation.network.public) implementation(projects.library.functional) implementation(projects.library.kotlinUtils) + implementation(projects.library.coroutines) api(libs.inject) implementation(libs.kermit) diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/DefaultRemoteMediator.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/DefaultRemoteMediator.kt new file mode 100644 index 00000000..64b68689 --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/DefaultRemoteMediator.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data + +import androidx.paging.ExperimentalPagingApi +import androidx.paging.LoadType +import androidx.paging.LoadType.APPEND +import androidx.paging.LoadType.PREPEND +import androidx.paging.LoadType.REFRESH +import androidx.paging.PagingState +import androidx.paging.RemoteMediator +import androidx.paging.RemoteMediator.InitializeAction.LAUNCH_INITIAL_REFRESH +import androidx.paging.RemoteMediator.InitializeAction.SKIP_INITIAL_REFRESH +import co.touchlab.kermit.Logger +import kotlinx.datetime.Instant +import ru.pixnews.domain.model.game.Game +import ru.pixnews.domain.model.game.GameField +import ru.pixnews.feature.calendar.data.repository.upcoming.IgdbPagingSourceKey +import ru.pixnews.feature.calendar.data.sync.SyncService + +@OptIn(ExperimentalPagingApi::class) +internal class DefaultRemoteMediator( + private val startDate: Instant, + private val requiredFields: Set, + private val syncService: SyncService, + logger: Logger = Logger, +) : RemoteMediator() { + private val logger = logger.withTag("DefaultRemoteMediator") + + override suspend fun initialize(): InitializeAction { + return if (syncService.isDataStale()) { + LAUNCH_INITIAL_REFRESH + } else { + SKIP_INITIAL_REFRESH + } + } + + // TODO: tests + override suspend fun load( + loadType: LoadType, + state: PagingState, + ): MediatorResult { + logger.d { "load() with loadType: $loadType" } + if (loadType == PREPEND) { + logger.d { "PREPEND: nothing to prepend (we refresh always from first page)" } + return MediatorResult.Success(endOfPaginationReached = true) + } + if (loadType == APPEND && state.lastItemOrNull() == null) { + logger.d { "APPEND: Last item is null. No more items after initial REFRESH, nothing to load" } + return MediatorResult.Success(endOfPaginationReached = true) + } + + val loadKey = if (loadType == APPEND) { + state.lastItemOrNull()!!.id + } else { + null + } + return try { + syncService.syncGameModes(false, true) + + val result = syncService.syncGames( + fullRefresh = loadType == REFRESH, + startDate = startDate, + minimumRequiredFields = requiredFields, + earlierThanGameId = loadKey, + ) + MediatorResult.Success(endOfPaginationReached = !result.hasMorePagesToLoad) + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + MediatorResult.Error(e) + } + } +} diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/DefaultIgdbSyncStatusRepository.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/DefaultIgdbSyncStatusRepository.kt new file mode 100644 index 00000000..947c21c9 --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/DefaultIgdbSyncStatusRepository.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.repository.status + +import com.squareup.anvil.annotations.ContributesBinding +import dagger.Reusable +import kotlinx.datetime.Instant +import ru.pixnews.foundation.database.PixnewsDatabase +import ru.pixnews.foundation.database.dao.IgdbSyncStatusDao +import ru.pixnews.foundation.database.entity.sync.IgdbSyncStatusEntity +import ru.pixnews.foundation.di.base.scopes.AppScope +import javax.inject.Inject + +@ContributesBinding(AppScope::class, boundType = IgdbSyncStatusRepository::class) +@Reusable +public class DefaultIgdbSyncStatusRepository( + private val dao: IgdbSyncStatusDao, +) : IgdbSyncStatusRepository { + @Inject + public constructor( + database: PixnewsDatabase, + ) : this(dao = database.igdbSyncStatusDao()) + + override suspend fun getInstantKey(key: String): Instant { + val epoch = dao.get(key)?.toLongOrNull() ?: 0 + return Instant.fromEpochSeconds(epoch) + } + + override suspend fun setInstantKey(key: String, value: Instant) { + dao.set(IgdbSyncStatusEntity(key, value.epochSeconds.toString())) + } +} diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/IgdbSyncStatusRepository.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/IgdbSyncStatusRepository.kt new file mode 100644 index 00000000..e7d9cb73 --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/status/IgdbSyncStatusRepository.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.repository.status + +import kotlinx.datetime.Instant + +public const val IGDB_GAME_MODE_MAX_UPDATED_AT_KEY: String = "igdb_game_mode_max_updated_at" +public const val IGDB_GAME_MODE_LAST_SYNC_KEY: String = "igdb_game_mode_last_sync" + +public interface IgdbSyncStatusRepository { + public suspend fun getInstantKey(key: String): Instant + public suspend fun setInstantKey(key: String, value: Instant) +} diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/DefaultUpcomingReleasePagingSourceFactory.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/DefaultUpcomingReleaseRepository.kt similarity index 76% rename from feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/DefaultUpcomingReleasePagingSourceFactory.kt rename to feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/DefaultUpcomingReleaseRepository.kt index d01dfdc5..c76bba94 100644 --- a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/DefaultUpcomingReleasePagingSourceFactory.kt +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/DefaultUpcomingReleaseRepository.kt @@ -1,37 +1,43 @@ /* - * Copyright (c) 2023, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ -package ru.pixnews.feature.calendar.data +package ru.pixnews.feature.calendar.data.repository.upcoming import androidx.paging.PagingState +import co.touchlab.kermit.Logger import com.squareup.anvil.annotations.ContributesBinding import kotlinx.datetime.Instant import ru.pixnews.domain.model.game.Game import ru.pixnews.domain.model.game.GameField -import ru.pixnews.feature.calendar.domain.upcoming.IgdbPagingSource +import ru.pixnews.feature.calendar.data.IgdbDataSource import ru.pixnews.foundation.di.base.scopes.AppScope import ru.pixnews.library.functional.network.NetworkRequestFailureException import javax.inject.Inject @ContributesBinding(AppScope::class) -public class DefaultUpcomingReleasePagingSourceFactory @Inject constructor( +public class DefaultUpcomingReleaseRepository @Inject constructor( private val igdbDataSource: IgdbDataSource, -) : IgdbPagingSource.Factory { - override fun create( + private val logger: Logger = Logger, +) : UpcomingReleaseRepository { + override fun createUpcomingReleasesPagingSource( startDate: Instant, requiredFields: Set, - ): IgdbPagingSource = UpcomingReleasePagingSource(startDate, requiredFields, igdbDataSource) + ): UpcomingReleasesPagingSource = UpcomingReleasePagingSource(startDate, requiredFields, igdbDataSource, logger) private class UpcomingReleasePagingSource( private val startDate: Instant, private val requiredFields: Set, private val igdbDataSource: IgdbDataSource, - ) : IgdbPagingSource() { + logger: Logger = Logger, + ) : UpcomingReleasesPagingSource() { + private val logger = logger.withTag("DefaultUpcomingReleasePagingSourceFactory") + override fun getRefreshKey( state: PagingState, ): IgdbPagingSourceKey? { + logger.i { "getRefreshKey(${state.anchorPosition})" } return state.anchorPosition?.let { anchorPosition -> val anchorPage = state.closestPageToPosition(anchorPosition) ?: return@let null @@ -54,6 +60,8 @@ public class DefaultUpcomingReleasePagingSourceFactory @Inject constructor( ): LoadResult { val offset = params.key?.offset ?: 0 val limit = params.loadSize + logger.i { "load($offset / $limit)" } + return igdbDataSource.fetchUpcomingReleases( startDate = startDate, requiredFields = requiredFields, diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/IgdbPagingSourceKey.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/IgdbPagingSourceKey.kt new file mode 100644 index 00000000..659b82d2 --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/IgdbPagingSourceKey.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.repository.upcoming + +@JvmInline +public value class IgdbPagingSourceKey( + public val offset: Int, +) diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleaseRepository.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleaseRepository.kt new file mode 100644 index 00000000..970ff1dd --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleaseRepository.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.repository.upcoming + +import kotlinx.datetime.Instant +import ru.pixnews.domain.model.game.GameField + +public interface UpcomingReleaseRepository { + public fun createUpcomingReleasesPagingSource( + startDate: Instant, + requiredFields: Set, + ): UpcomingReleasesPagingSource +} diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleasesPagingSource.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleasesPagingSource.kt new file mode 100644 index 00000000..3e57f8dd --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/UpcomingReleasesPagingSource.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.repository.upcoming + +import androidx.paging.PagingSource +import ru.pixnews.domain.model.game.Game + +public abstract class UpcomingReleasesPagingSource : PagingSource() diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/IgdbGameModeSyncService.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/IgdbGameModeSyncService.kt new file mode 100644 index 00000000..501d6140 --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/IgdbGameModeSyncService.kt @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.sync + +import androidx.room.withTransaction +import co.touchlab.kermit.Logger +import com.squareup.anvil.annotations.optional.SingleIn +import kotlinx.coroutines.CancellationException +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import ru.pixnews.feature.calendar.data.IgdbDataSource +import ru.pixnews.feature.calendar.data.model.GameModeIgdbDto +import ru.pixnews.feature.calendar.data.repository.status.IGDB_GAME_MODE_LAST_SYNC_KEY +import ru.pixnews.feature.calendar.data.repository.status.IGDB_GAME_MODE_MAX_UPDATED_AT_KEY +import ru.pixnews.feature.calendar.data.repository.status.IgdbSyncStatusRepository +import ru.pixnews.feature.calendar.data.sync.policy.SyncPolicy +import ru.pixnews.feature.calendar.data.sync.policy.SyncRequiredResult +import ru.pixnews.feature.calendar.data.sync.policy.isSyncRequired +import ru.pixnews.foundation.database.PixnewsDatabase +import ru.pixnews.foundation.database.entity.mode.GameModeEntity +import ru.pixnews.foundation.database.entity.mode.GameModeNameEntity +import ru.pixnews.foundation.database.model.LanguageCodeWrapper +import ru.pixnews.foundation.di.base.scopes.AppScope +import ru.pixnews.library.functional.Result +import ru.pixnews.library.functional.completeFailure +import ru.pixnews.library.functional.completeSuccess +import ru.pixnews.library.functional.network.NetworkRequestFailureException +import ru.pixnews.library.internationalization.language.LanguageCode +import javax.inject.Inject +import kotlin.time.Duration + +/** + * Loads fresh Game Modes from IGDB and updates database + */ +@SingleIn(AppScope::class) +public class IgdbGameModeSyncService( + private val database: PixnewsDatabase, + private val syncStatusRepository: IgdbSyncStatusRepository, + private val igdbDataSource: IgdbDataSource, + private val syncPolicy: SyncPolicy = DEFAULT_SYNC_POLICY, + private val clock: Clock = Clock.System, + logger: Logger = Logger, +) { + private val logger = logger.withTag("IgdbGameModeSyncService") + private val gameModeDao = database.gameModeDao() + private val gameModeNameDao = database.gameModeNameDao() + + @Inject + public constructor( + database: PixnewsDatabase, + syncStatusRepository: IgdbSyncStatusRepository, + igdbDataSource: IgdbDataSource, + logger: Logger, + ) : this( + database = database, + syncStatusRepository = syncStatusRepository, + igdbDataSource = igdbDataSource, + logger = logger, + syncPolicy = DEFAULT_SYNC_POLICY, + clock = Clock.System, + ) + + public suspend fun sync( + forceSync: Boolean = false, + forceFullReload: Boolean = false, + ): Result = try { + syncThrowable(forceSync, forceFullReload) + Unit.completeSuccess() + } catch (cancellationException: CancellationException) { + throw cancellationException + } catch (@Suppress("TooGenericExceptionCaught") throwable: Throwable) { + throwable.completeFailure() + } + + private suspend fun syncThrowable( + forceSync: Boolean, + forceFullReload: Boolean, + ) { + logger.i { "Sync GameModes" } + val lastUpdateMetaInfo = database.withTransaction { + getLastUpdateMetaInfo() + } + val syncPolicyStatus = syncPolicy.isSyncRequired( + lastSyncTime = lastUpdateMetaInfo.lastSyncTime, + forceSync = forceSync, + forceFullReload = forceFullReload, + ) + if (syncPolicyStatus !is SyncRequiredResult.Required) { + logger.i { "Sync not required: $syncPolicyStatus" } + return + } + + val newGameModes = downloadNewGameModes( + lastUpdatedAt = lastUpdateMetaInfo.lastMaxUpdatedAt.takeIf { !forceFullReload }, + ) + if (newGameModes.isEmpty()) { + logger.i { "No new game modes" } + return + } + upsertGameModes(newGameModes, lastUpdateMetaInfo) + } + + private suspend fun downloadNewGameModes( + lastUpdatedAt: Instant?, + ): List { + val result = igdbDataSource.getGameModes( + updatedLaterThan = lastUpdatedAt, + offset = 0, + limit = 100, + ) + + return result.fold( + ifLeft = { + throw NetworkRequestFailureException(it, "Can not download updated game modes") + }, + ifRight = { it }, + ) + } + + private suspend fun upsertGameModes( + modes: List, + lastUpdateMetaInfo: LastUpdateMetaInfo, + ) = database.withTransaction { + val newMetaInfo = getLastUpdateMetaInfo() + if (newMetaInfo.lastSyncTime != lastUpdateMetaInfo.lastSyncTime) { + logger.i { "Another sync active" } + return@withTransaction + } + for (mode in modes) { + val gameModeEntity = GameModeEntity(slug = mode.igdbSlug) + val id = gameModeDao.insertOrGetId(gameModeEntity) + if (id != -1L) { + val nameEntity = GameModeNameEntity( + gameModeId = id, + languageCode = LanguageCodeWrapper(LanguageCode.ENGLISH), + name = mode.mode.name, + ) + val id = gameModeNameDao.upsert(nameEntity) + if (id == -1L) { + logger.i { "Can not insert game name for `$mode`" } + } + } else { + logger.i { "Can not insert game mode with slug `${mode.igdbSlug}`" } + } + } + + setLastUpdateMetaInfo( + LastUpdateMetaInfo( + lastSyncTime = clock.now(), + lastMaxUpdatedAt = modes.maxOf { it.updatedAt }, + ), + ) + } + + private suspend fun getLastUpdateMetaInfo(): LastUpdateMetaInfo { + val lastUpdatedAt = syncStatusRepository.getInstantKey(IGDB_GAME_MODE_MAX_UPDATED_AT_KEY) + val lastSyncTime = syncStatusRepository.getInstantKey(IGDB_GAME_MODE_LAST_SYNC_KEY) + return LastUpdateMetaInfo(lastSyncTime, lastUpdatedAt) + } + + private suspend fun setLastUpdateMetaInfo( + info: LastUpdateMetaInfo, + ) { + syncStatusRepository.setInstantKey(IGDB_GAME_MODE_MAX_UPDATED_AT_KEY, info.lastMaxUpdatedAt) + syncStatusRepository.setInstantKey(IGDB_GAME_MODE_LAST_SYNC_KEY, info.lastSyncTime) + } + + private class LastUpdateMetaInfo( + val lastSyncTime: Instant, + val lastMaxUpdatedAt: Instant, + ) + + private companion object { + val DEFAULT_SYNC_POLICY = SyncPolicy(minPeriod = Duration.ZERO) + } +} diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/SyncService.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/SyncService.kt new file mode 100644 index 00000000..c357494d --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/SyncService.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.sync + +import co.touchlab.kermit.Logger +import com.squareup.anvil.annotations.optional.SingleIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.datetime.Instant +import ru.pixnews.domain.model.game.GameField +import ru.pixnews.domain.model.id.GameId +import ru.pixnews.foundation.coroutines.IoCoroutineDispatcherProvider +import ru.pixnews.foundation.coroutines.RootCoroutineScope +import ru.pixnews.foundation.database.PixnewsDatabase +import ru.pixnews.foundation.di.base.scopes.AppScope +import ru.pixnews.library.coroutines.newChildSupervisorScope +import javax.inject.Inject + +@SingleIn(AppScope::class) +public class SyncService( + private val database: PixnewsDatabase, + parentScope: CoroutineScope, + databaseDispatcher: CoroutineDispatcher, + private val gameModeSyncService: IgdbGameModeSyncService, + logger: Logger, +) { + // TODO: remove scope? + private val log = logger.withTag(TAG) + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + log.e(throwable) { "Unhandled coroutine exception in $TAG" } + } + + @Suppress("UnusedPrivateProperty") + private val scope: CoroutineScope = parentScope.newChildSupervisorScope( + databaseDispatcher + exceptionHandler + CoroutineName("$TAG scope"), + ) + + @Inject + public constructor( + database: PixnewsDatabase, + rootScope: RootCoroutineScope, + databaseDispatcher: IoCoroutineDispatcherProvider, + gameModeSyncService: IgdbGameModeSyncService, + logger: Logger, + ) : this( + database = database, + parentScope = rootScope.newChildSupervisorScope(databaseDispatcher.get()), + databaseDispatcher = databaseDispatcher.get(), + gameModeSyncService = gameModeSyncService, + logger = logger, + ) + + public suspend fun syncGameModes( + forceSync: Boolean, + forceFullReload: Boolean = false, + ) { + val result = gameModeSyncService.sync(forceSync, forceFullReload) + result.onLeft { exception -> + log.e(exception) { "Sync game modes failed" } + } + } + + public suspend fun syncGames( + fullRefresh: Boolean, + startDate: Instant, + minimumRequiredFields: Set, + earlierThanGameId: GameId?, + ): SyncGamesResult { + log.i { "syncGames($fullRefresh, $startDate, $minimumRequiredFields, $earlierThanGameId)" } + + // TODO + return SyncGamesResult( + lastGameId = earlierThanGameId, + hasMorePagesToLoad = false, + ) + } + + /** + * Returns true if the data in the database is outdated and requires a complete refresh + */ + @Suppress("FunctionOnlyReturningConstant") + public suspend fun isDataStale(): Boolean { + // TODO: + return true + } + + public data class SyncGamesResult( + val lastGameId: GameId?, + val hasMorePagesToLoad: Boolean = false, + ) + + private companion object { + private const val TAG = "SyncService" + } +} diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicy.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicy.kt new file mode 100644 index 00000000..d9b1ba0c --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicy.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.sync.policy + +import kotlin.time.Duration + +public data class SyncPolicy( + val minPeriod: Duration, +) diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyExt.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyExt.kt new file mode 100644 index 00000000..d207990b --- /dev/null +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyExt.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.feature.calendar.data.sync.policy + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +public fun SyncPolicy.isSyncRequired( + lastSyncTime: Instant, + currentTime: Instant = Clock.System.now(), + forceSync: Boolean = false, + forceFullReload: Boolean = false, +): SyncRequiredResult { + if (forceSync || forceFullReload) { + return SyncRequiredResult.Required(isForced = true, reason = "Forced") + } + + val nextSyncMinTime = lastSyncTime + this.minPeriod + if (nextSyncMinTime > currentTime) { + return SyncRequiredResult.NotRequired(reason = "Next sync not earlier than $nextSyncMinTime") + } + + return SyncRequiredResult.Required(isForced = false, reason = "") +} + +public sealed class SyncRequiredResult( + public open val reason: String = "", +) { + public data class Required( + val isForced: Boolean? = null, + override val reason: String = "", + ) : SyncRequiredResult(reason) + + public data class NotRequired( + override val reason: String = "", + ) : SyncRequiredResult(reason) +} diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/DefaultObserveUpcomingReleasesByDateUseCase.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/DefaultObserveUpcomingReleasesByDateUseCase.kt index 7724d927..91d601d7 100644 --- a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/DefaultObserveUpcomingReleasesByDateUseCase.kt +++ b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/DefaultObserveUpcomingReleasesByDateUseCase.kt @@ -5,11 +5,13 @@ package ru.pixnews.feature.calendar.domain.upcoming +import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map import com.squareup.anvil.annotations.ContributesBinding +import com.squareup.anvil.annotations.optional.SingleIn import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -22,8 +24,11 @@ import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime import ru.pixnews.domain.model.UpcomingReleaseTimeCategory.CURRENT_MONTH import ru.pixnews.domain.model.game.GameField +import ru.pixnews.feature.calendar.data.DefaultRemoteMediator import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease +import ru.pixnews.feature.calendar.data.repository.upcoming.UpcomingReleaseRepository +import ru.pixnews.feature.calendar.data.sync.SyncService import ru.pixnews.foundation.di.base.scopes.AppScope import javax.inject.Inject @@ -31,20 +36,25 @@ import javax.inject.Inject boundType = ObserveUpcomingReleasesByDateUseCase::class, scope = AppScope::class, ) +@SingleIn(AppScope::class) public class DefaultObserveUpcomingReleasesByDateUseCase( - private val igdbPagingSourceFactory: IgdbPagingSource.Factory, + private val upcomingReleaseRepository: UpcomingReleaseRepository, + private val syncService: SyncService, private val clock: Clock, private val tzProvider: () -> TimeZone, ) : ObserveUpcomingReleasesByDateUseCase { @Inject public constructor( - igdbPagingSourceFactory: IgdbPagingSource.Factory, + igdbPagingSourceFactory: UpcomingReleaseRepository, + syncService: SyncService, ) : this( - igdbPagingSourceFactory = igdbPagingSourceFactory, + upcomingReleaseRepository = igdbPagingSourceFactory, + syncService = syncService, clock = System, tzProvider = TimeZone.Companion::currentSystemDefault, ) + @OptIn(ExperimentalPagingApi::class) public override fun createUpcomingReleasesObservable( requiredFields: Set, ): Flow> { @@ -55,8 +65,13 @@ public class DefaultObserveUpcomingReleasesByDateUseCase( val pager = Pager( initialKey = null, config = PagingConfig(pageSize = INITIAL_PAGING_SIZE), + remoteMediator = DefaultRemoteMediator( + startDate = startDate, + requiredFields = requiredFields, + syncService = syncService, + ), pagingSourceFactory = { - igdbPagingSourceFactory.create( + upcomingReleaseRepository.createUpcomingReleasesPagingSource( startDate = startDate, requiredFields = requiredFieldsSet, ) diff --git a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/IgdbPagingSource.kt b/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/IgdbPagingSource.kt deleted file mode 100644 index 798d71fb..00000000 --- a/feature/calendar/data/src/main/kotlin/ru/pixnews/feature/calendar/domain/upcoming/IgdbPagingSource.kt +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright (c) 2023, the Pixnews project authors and contributors. Please see the AUTHORS file for details. - * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. - */ - -package ru.pixnews.feature.calendar.domain.upcoming - -import androidx.paging.PagingSource -import kotlinx.datetime.Instant -import ru.pixnews.domain.model.game.Game -import ru.pixnews.domain.model.game.GameField -import ru.pixnews.feature.calendar.domain.upcoming.IgdbPagingSource.IgdbPagingSourceKey - -public abstract class IgdbPagingSource : PagingSource() { - @JvmInline - public value class IgdbPagingSourceKey( - public val offset: Int, - ) - - public fun interface Factory { - public fun create( - startDate: Instant, - requiredFields: Set, - ): IgdbPagingSource - } -} diff --git a/feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/UpcomingReleasePagingSourceFactoryTest.kt b/feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/DefaultUpcomingReleaseRepositoryTest.kt similarity index 86% rename from feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/UpcomingReleasePagingSourceFactoryTest.kt rename to feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/DefaultUpcomingReleaseRepositoryTest.kt index 3a9105ff..a5916bbc 100644 --- a/feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/UpcomingReleasePagingSourceFactoryTest.kt +++ b/feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/repository/upcoming/DefaultUpcomingReleaseRepositoryTest.kt @@ -1,12 +1,13 @@ /* - * Copyright (c) 2023, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. */ -package ru.pixnews.feature.calendar.data +package ru.pixnews.feature.calendar.data.repository.upcoming import androidx.paging.PagingConfig -import androidx.paging.PagingSource.LoadResult +import androidx.paging.PagingSource.LoadResult.Error +import androidx.paging.PagingSource.LoadResult.Page import androidx.paging.testing.TestPager import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldContainExactly @@ -24,8 +25,9 @@ import ru.pixnews.domain.model.game.GameField.Id import ru.pixnews.domain.model.game.GameFixtures import ru.pixnews.domain.model.game.game.halfLife3 import ru.pixnews.domain.model.id.GameId -import ru.pixnews.feature.calendar.data.UpcomingReleasePagingSourceFactoryTest.FakeTracingDataSource.FetchUpcomingReleasesRequest -import ru.pixnews.feature.calendar.domain.upcoming.IgdbPagingSource.IgdbPagingSourceKey +import ru.pixnews.feature.calendar.data.IgdbDataSource +import ru.pixnews.feature.calendar.data.model.GameModeIgdbDto +import ru.pixnews.feature.calendar.data.repository.upcoming.DefaultUpcomingReleaseRepositoryTest.FakeTracingDataSource.FetchUpcomingReleasesRequest import ru.pixnews.library.functional.completeFailure import ru.pixnews.library.functional.completeSuccess import ru.pixnews.library.functional.network.NetworkRequestFailure.ApiFailure @@ -36,14 +38,14 @@ import ru.pixnews.library.test.MainCoroutineExtension import java.net.NoRouteToHostException import java.util.Collections -class UpcomingReleasePagingSourceFactoryTest { +class DefaultUpcomingReleaseRepositoryTest { @JvmField @RegisterExtension var coroutinesExt = MainCoroutineExtension() private val dataSource = FakeTracingDataSource() private val startDate = Instant.parse("2024-11-01T00:00:00Z") - private val pagingSource = DefaultUpcomingReleasePagingSourceFactory(dataSource) - .create( + private val pagingSource = DefaultUpcomingReleaseRepository(dataSource) + .createUpcomingReleasesPagingSource( startDate = startDate, requiredFields = GAME_LIST_REQUIRED_FIELDS, ) @@ -51,7 +53,7 @@ class UpcomingReleasePagingSourceFactoryTest { @Test fun `load() should load initial data`() = coroutinesExt.runTest { - val result = pager.refresh() as LoadResult.Page + val result = pager.refresh() as Page result.data.map { it.id.toString() }.shouldContainExactly((1..15).map { "igdb-$it" }) result.nextKey shouldBe IgdbPagingSourceKey(15) @@ -68,7 +70,7 @@ class UpcomingReleasePagingSourceFactoryTest { refresh() append() append() - } as LoadResult.Page + } as Page result.data.map { it.id.toString() } .shouldContainExactly((21..25).map { "igdb-$it" }) @@ -89,7 +91,7 @@ class UpcomingReleasePagingSourceFactoryTest { append() append() append() - } as LoadResult.Page + } as Page result.data.shouldBeEmpty() result.nextKey shouldBe null @@ -131,7 +133,7 @@ class UpcomingReleasePagingSourceFactoryTest { dataSource.mockResponse = { _, _, _, _ -> NetworkFailure(NoRouteToHostException()).completeFailure() } - val result = pager.refresh() as LoadResult.Error + val result = pager.refresh() as Error result.throwable.run { shouldBeInstanceOf() failure.shouldBeInstanceOf() @@ -146,7 +148,8 @@ class UpcomingReleasePagingSourceFactoryTest { offset: Int, limit: Int, ) -> NetworkResult> = DEFAULT_RESPONSE - val requests: MutableList = Collections.synchronizedList(mutableListOf()) + val requests: MutableList = + Collections.synchronizedList(mutableListOf()) override suspend fun fetchUpcomingReleases( startDate: Instant, @@ -165,6 +168,14 @@ class UpcomingReleasePagingSourceFactoryTest { return mockResponse(startDate, requiredFields, offset, limit) } + override suspend fun getGameModes( + updatedSince: Instant?, + offset: Int, + limit: Int, + ): NetworkResult> { + error("Not implemented") + } + data class FetchUpcomingReleasesRequest( val startDate: Instant, val requiredFields: Set, diff --git a/feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyEvaluatorTest.kt b/feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyEvaluatorTest.kt new file mode 100644 index 00000000..e2284d60 --- /dev/null +++ b/feature/calendar/data/src/test/kotlin/ru/pixnews/feature/calendar/data/sync/policy/SyncPolicyEvaluatorTest.kt @@ -0,0 +1,52 @@ +package ru.pixnews.feature.calendar.data.sync.policy + +import io.kotest.matchers.shouldBe +import kotlinx.datetime.Instant +import kotlinx.datetime.Instant.Companion +import org.junit.jupiter.api.DynamicTest +import org.junit.jupiter.api.DynamicTest.dynamicTest +import org.junit.jupiter.api.TestFactory +import kotlin.time.Duration.Companion.seconds + +class SyncPolicyEvaluatorTest { + @TestFactory + fun `isSyncRequired should return correct value`(): Iterable = listOf( + SyncRequiredTestCase( + currentTime = "2024-01-26T14:40:09Z", + expectedResult = SyncRequiredResult.NotRequired("Next sync not earlier than 2024-01-26T14:40:10Z"), + ), + SyncRequiredTestCase( + currentTime = "2024-01-26T14:40:09Z", + forceSync = true, + expectedResult = SyncRequiredResult.Required(isForced = true, "Forced"), + ), + SyncRequiredTestCase( + currentTime = "2024-01-26T14:40:10Z", + expectedResult = SyncRequiredResult.Required(isForced = false, ""), + ), + ).map { testCase -> + dynamicTest( + "isSyncRequired(${testCase.syncPolicy.minPeriod}, " + + "${testCase.lastSyncTime}), " + + "${testCase.currentTime}, " + + "${testCase.forceSync}" + + ") should return ${testCase.expectedResult}", + ) { + val result = testCase.syncPolicy.isSyncRequired( + lastSyncTime = Instant.parse(testCase.lastSyncTime), + currentTime = Companion.parse(testCase.currentTime), + forceSync = testCase.forceSync, + ) + + result shouldBe testCase.expectedResult + } + } + + private data class SyncRequiredTestCase( + val syncPolicy: SyncPolicy = SyncPolicy(minPeriod = 10.seconds), + val lastSyncTime: String = "2024-01-26T14:40:00Z", + val currentTime: String = "2024-01-26T14:40:10Z", + val forceSync: Boolean = false, + val expectedResult: SyncRequiredResult, + ) +} diff --git a/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/DefaultIgdbDataSource.kt b/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/DefaultIgdbDataSource.kt index 24104330..e1b71d27 100644 --- a/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/DefaultIgdbDataSource.kt +++ b/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/DefaultIgdbDataSource.kt @@ -15,6 +15,8 @@ import kotlinx.datetime.Instant import ru.pixnews.domain.model.game.Game import ru.pixnews.domain.model.game.GameField import ru.pixnews.feature.calendar.data.IgdbDataSource +import ru.pixnews.feature.calendar.data.model.GameModeIgdbDto +import ru.pixnews.feature.calendar.datasource.igdb.converter.game.IgdbGameGameModeConverter import ru.pixnews.feature.calendar.datasource.igdb.converter.game.igdbFieldConverter import ru.pixnews.feature.calendar.datasource.igdb.converter.game.toGame import ru.pixnews.feature.calendar.datasource.igdb.converter.toNetworkResult @@ -25,6 +27,8 @@ import ru.pixnews.igdbclient.IgdbEndpoint import ru.pixnews.igdbclient.apicalypse.SortOrder.DESC import ru.pixnews.igdbclient.apicalypse.apicalypseQuery import ru.pixnews.igdbclient.dsl.field.IgdbRequestField +import ru.pixnews.igdbclient.dsl.field.field +import ru.pixnews.igdbclient.model.GameMode import ru.pixnews.library.functional.network.NetworkRequestFailure import ru.pixnews.library.functional.network.NetworkResult import javax.inject.Inject @@ -67,26 +71,63 @@ public class DefaultIgdbDataSource( query = query, ).toNetworkResult() - val result: NetworkResult> = igdbGameResult.flatMap { gameResult -> - withContext(backgroundDispatcher) { - catch { - logger.d { "Received games: ${gameResult.games.prettyPrint()}" } - gameResult.games.map(IgdbGame::toGame) - }.mapLeft { error -> - logger.e(error) { "Mapping error" } - NetworkRequestFailure.ApiFailure(error) - } + val result: NetworkResult> = igdbGameResult.mapSuccessOnBackground { gameResult -> + logger.v { "Received games ${gameResult.games.prettyPrintGames()}" } + gameResult.games.map(IgdbGame::toGame) + } + + return result + } + + override suspend fun getGameModes( + updatedLaterThan: Instant?, + offset: Int, + limit: Int, + ): NetworkResult> { + val converter = IgdbGameGameModeConverter + val query = apicalypseQuery { + fields( + fieldList = (converter.getRequiredFields(GameMode.field) + GameMode.field.updated_at).toTypedArray(), + ) + if (updatedLaterThan != null) { + where("updated_at > ${updatedLaterThan.epochSeconds}") } + offset(offset) + limit(limit) + } + logger.d { "REQ IGDB query: '$query'" } + + val igdbResult = igdbClient.execute( + endpoint = IgdbEndpoint.GAME_MODE, + query = query, + ).toNetworkResult() + + val result: NetworkResult> = igdbResult.mapSuccessOnBackground { result -> + logger.v { "Received game modes ${result.gamemodes}" } + result.gamemodes.map(converter::toGameModeIgdbDto) } return result } + private suspend fun NetworkResult.mapSuccessOnBackground( + mapper: (I) -> O, + ): NetworkResult = flatMap { result -> + withContext(backgroundDispatcher) { + catch { + mapper(result) + }.mapLeft { error: Throwable -> + logger.v(error) { "Mapping error" } + NetworkRequestFailure.ApiFailure(error) + } + } + } + private fun Set.toIgdbRequestFields(): Set> = this.flatMap { it.igdbFieldConverter.getRequiredFields() }.toSet() - private fun List.prettyPrint() = joinToString(",\n") { game -> + private fun List.prettyPrintGames() = joinToString(",\n") { game -> val releaseDates = game.release_dates.map { "{release_date: ${it.category}-${it.date}}" } diff --git a/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/converter/game/IgdbGameGameModeConverter.kt b/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/converter/game/IgdbGameGameModeConverter.kt index 67fc88b4..a7b753ea 100644 --- a/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/converter/game/IgdbGameGameModeConverter.kt +++ b/feature/calendar/datasource-igdb/src/main/kotlin/ru/pixnews/feature/calendar/datasource/igdb/converter/game/IgdbGameGameModeConverter.kt @@ -7,23 +7,35 @@ package ru.pixnews.feature.calendar.datasource.igdb.converter.game import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.toImmutableSet +import kotlinx.datetime.Instant import ru.pixnews.domain.model.game.GameMode import ru.pixnews.domain.model.game.GameMode.Other +import ru.pixnews.domain.model.id.GameModeId import ru.pixnews.domain.model.util.Ref import ru.pixnews.domain.model.util.Ref.FullObject +import ru.pixnews.feature.calendar.data.model.GameModeIgdbDto import ru.pixnews.feature.calendar.datasource.igdb.converter.util.errorFieldNotRequested +import ru.pixnews.feature.calendar.datasource.igdb.converter.util.fieldShouldBeRequestedError import ru.pixnews.feature.calendar.datasource.igdb.converter.util.requireFieldInitialized import ru.pixnews.feature.calendar.datasource.igdb.model.id.IgdbGameModeId import ru.pixnews.igdbclient.dsl.field.GameFieldDsl +import ru.pixnews.igdbclient.dsl.field.GameModeFieldDsl import ru.pixnews.igdbclient.dsl.field.IgdbRequestField +import ru.pixnews.igdbclient.dsl.field.field import ru.pixnews.igdbclient.model.Game import ru.pixnews.igdbclient.model.GameMode as IgdbGameMode internal object IgdbGameGameModeConverter : IgdbGameFieldConverter>> { - override fun getRequiredFields(from: GameFieldDsl): List> = listOf( - from.game_modes.id, - from.game_modes.name, - from.game_modes.slug, + override fun getRequiredFields(from: GameFieldDsl): List> = getRequiredFields( + from.game_modes, + ) + + fun getRequiredFields( + from: GameModeFieldDsl = IgdbGameMode.field, + ): List> = listOf( + from.id, + from.name, + from.slug, ) override fun convert(game: Game): ImmutableSet> = game.game_modes @@ -45,7 +57,21 @@ internal object IgdbGameGameModeConverter : IgdbGameFieldConverter>) { + fun `should convert game modes`(testData: Pair>) { val result = convert( Game(game_modes = listOf(testData.first)), ) @@ -71,6 +74,18 @@ class IgdbGameGameModeConverterTest { } } + @Test + fun `toGameModeIgdbDto() should convert game modes`() { + val src = IgdbGameModeFixtures.singlePlayer + val result = toGameModeIgdbDto(src) + + result shouldBeEqual GameModeIgdbDto( + mode = SinglePlayer, + igdbSlug = src.slug, + updatedAt = Instant.fromEpochSeconds(13_2321_6000), + ) + } + companion object { @JvmStatic fun gameModeTestSource(): List>> { @@ -104,7 +119,7 @@ class IgdbGameGameModeConverterTest { id = 100, name = "Test Game Mode", slug = "test-game-mode", - ) to FullObject(Other("Test Game Mode")), + ) to FullObject(Other("Test Game Mode", GameModeId("test-game-mode"))), ) } } diff --git a/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/CalendarViewModel.kt b/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/CalendarViewModel.kt index 1f67be52..6f912b32 100644 --- a/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/CalendarViewModel.kt +++ b/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/CalendarViewModel.kt @@ -23,7 +23,7 @@ import kotlinx.datetime.TimeZone import ru.pixnews.anvil.codegen.viewmodel.inject.ContributesViewModel import ru.pixnews.feature.calendar.converter.UpcomingGameListConverter.toCalendarListItem import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.model.CALENDAR_LIST_ITEM_GAME_FIELDS import ru.pixnews.feature.calendar.model.CalendarListItem import ru.pixnews.feature.calendar.model.CalendarScreenState diff --git a/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverter.kt b/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverter.kt index 8aa55b8d..315bd885 100644 --- a/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverter.kt +++ b/feature/calendar/public/src/main/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverter.kt @@ -36,7 +36,7 @@ import ru.pixnews.domain.model.game.GameGenre import ru.pixnews.domain.model.game.GamePlatform import ru.pixnews.domain.model.util.Ref import ru.pixnews.domain.model.util.getObjectOrThrow -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.model.CalendarListItem import ru.pixnews.feature.calendar.model.CalendarListPixnewsGameUi import ru.pixnews.feature.calendar.model.CalendarListTitle diff --git a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/CalendarViewModelTest.kt b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/CalendarViewModelTest.kt index 9962b80c..64a9d8a5 100644 --- a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/CalendarViewModelTest.kt +++ b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/CalendarViewModelTest.kt @@ -20,7 +20,7 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import ru.pixnews.domain.model.game.GameField import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.domain.upcoming.DefaultObserveUpcomingReleasesByDateUseCase import ru.pixnews.feature.calendar.model.InitialLoad import ru.pixnews.foundation.featuretoggles.ExperimentKey diff --git a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/ListOrderTestExpectedItem.kt b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/ListOrderTestExpectedItem.kt index c4e231f9..57a163d1 100644 --- a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/ListOrderTestExpectedItem.kt +++ b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/ListOrderTestExpectedItem.kt @@ -7,7 +7,7 @@ package ru.pixnews.feature.calendar.converter import ru.pixnews.domain.model.datetime.Date import ru.pixnews.domain.model.id.GameId -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.model.CalendarListItem import ru.pixnews.feature.calendar.model.CalendarListPixnewsGameUi import ru.pixnews.feature.calendar.model.CalendarListTitle diff --git a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverterTest.kt b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverterTest.kt index 8c4c3279..1aa679c7 100644 --- a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverterTest.kt +++ b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/converter/UpcomingGameListConverterTest.kt @@ -30,7 +30,7 @@ import ru.pixnews.feature.calendar.converter.ListOrderTestExpectedItem.Companion import ru.pixnews.feature.calendar.converter.ListOrderTestExpectedItem.Companion.titleYearMonthDay import ru.pixnews.feature.calendar.converter.ListOrderTestExpectedItem.Companion.toListOrderTestExpectedItem import ru.pixnews.feature.calendar.converter.UpcomingGameListConverter.toCalendarListItem -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.fixture.UpcomingReleaseDateFixtures.CurrentMonth import ru.pixnews.feature.calendar.fixture.UpcomingReleaseDateFixtures.CurrentMonth.exactDateLater25 import ru.pixnews.feature.calendar.fixture.UpcomingReleaseDateFixtures.CurrentMonth.exactDateLater26 diff --git a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/fixture/UpcomingReleasesFixtures.kt b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/fixture/UpcomingReleasesFixtures.kt index f27cebdd..da98a55a 100644 --- a/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/fixture/UpcomingReleasesFixtures.kt +++ b/feature/calendar/public/src/test/kotlin/ru/pixnews/feature/calendar/fixture/UpcomingReleasesFixtures.kt @@ -21,7 +21,7 @@ import ru.pixnews.domain.model.game.GameFixtures import ru.pixnews.domain.model.game.game.halfLife3 import ru.pixnews.domain.model.id.GameId import ru.pixnews.domain.model.locale.Localized -import ru.pixnews.feature.calendar.data.domain.upcoming.UpcomingRelease +import ru.pixnews.feature.calendar.data.domain.upcoming.ObserveUpcomingReleasesByDateUseCase.UpcomingRelease import ru.pixnews.feature.calendar.fixture.UpcomingReleaseDateFixtures.CurrentMonth import ru.pixnews.feature.calendar.fixture.UpcomingReleaseDateFixtures.CurrentQuarter import ru.pixnews.feature.calendar.fixture.UpcomingReleaseDateFixtures.CurrentYear diff --git a/foundation/appconfig/main/ru/pixnews/foundation/appconfig/AppConfigExt.kt b/foundation/appconfig/main/ru/pixnews/foundation/appconfig/AppConfigExt.kt new file mode 100644 index 00000000..8ee4dd40 --- /dev/null +++ b/foundation/appconfig/main/ru/pixnews/foundation/appconfig/AppConfigExt.kt @@ -0,0 +1,9 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.foundation.appconfig + +@Suppress("FUNCTION_BOOLEAN_PREFIX") +public fun AppConfig.logDatabaseQueries(): Boolean = this.isDebug diff --git a/foundation/database/database.gradle.kts b/foundation/database/database.gradle.kts index 566fabd0..ffdff7b9 100644 --- a/foundation/database/database.gradle.kts +++ b/foundation/database/database.gradle.kts @@ -45,6 +45,7 @@ dependencies { api(projects.foundation.domainModel) api(libs.inject) api(libs.okio) + implementation(libs.androidx.core.ktx) implementation(projects.library.kotlinDatetimeUtils) testImplementation(projects.library.test) diff --git a/foundation/database/schemas/ru.pixnews.foundation.database.PixnewsDatabase/1.json b/foundation/database/schemas/ru.pixnews.foundation.database.PixnewsDatabase/1.json index 04323df8..e55aee41 100644 --- a/foundation/database/schemas/ru.pixnews.foundation.database.PixnewsDatabase/1.json +++ b/foundation/database/schemas/ru.pixnews.foundation.database.PixnewsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "0f96119ff3ee19104e6062f5adaba7c7", + "identityHash": "8b07b088df3afd4247059af7cc4d8f15", "entities": [ { "tableName": "companyDescription", @@ -2194,6 +2194,32 @@ } ] }, + { + "tableName": "igdbSyncStatus", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, { "tableName": "platform", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `slug` TEXT NOT NULL COLLATE NOCASE)", @@ -2410,7 +2436,7 @@ "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, '0f96119ff3ee19104e6062f5adaba7c7')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8b07b088df3afd4247059af7cc4d8f15')" ] } } \ No newline at end of file diff --git a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/PixnewsDatabase.kt b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/PixnewsDatabase.kt index d3d1309a..96db61e7 100644 --- a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/PixnewsDatabase.kt +++ b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/PixnewsDatabase.kt @@ -25,6 +25,9 @@ import ru.pixnews.foundation.database.converter.LanguageCodeConverter import ru.pixnews.foundation.database.converter.PegiRatingConverter import ru.pixnews.foundation.database.converter.VotesDistributionConverter import ru.pixnews.foundation.database.dao.GameDao +import ru.pixnews.foundation.database.dao.GameModeDao +import ru.pixnews.foundation.database.dao.GameModeNameDao +import ru.pixnews.foundation.database.dao.IgdbSyncStatusDao import ru.pixnews.foundation.database.entity.GameCompanyEntity import ru.pixnews.foundation.database.entity.GameGameModeEntity import ru.pixnews.foundation.database.entity.GameGameSeriesEntity @@ -57,6 +60,7 @@ import ru.pixnews.foundation.database.entity.platform.PlatformEntity import ru.pixnews.foundation.database.entity.platform.PlatformNameEntity import ru.pixnews.foundation.database.entity.series.GameSeriesEntity import ru.pixnews.foundation.database.entity.series.GameSeriesNameEntity +import ru.pixnews.foundation.database.entity.sync.IgdbSyncStatusEntity @Database( entities = [ @@ -88,6 +92,7 @@ import ru.pixnews.foundation.database.entity.series.GameSeriesNameEntity GameVideoEntity::class, GenreEntity::class, GenreNameEntity::class, + IgdbSyncStatusEntity::class, PlatformEntity::class, PlatformNameEntity::class, PlayerPerspectiveEntity::class, @@ -115,5 +120,8 @@ import ru.pixnews.foundation.database.entity.series.GameSeriesNameEntity ) public abstract class PixnewsDatabase : RoomDatabase() { public abstract fun gameDao(): GameDao + public abstract fun gameModeDao(): GameModeDao + public abstract fun gameModeNameDao(): GameModeNameDao + public abstract fun igdbSyncStatusDao(): IgdbSyncStatusDao public companion object } diff --git a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeDao.kt b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeDao.kt new file mode 100644 index 00000000..07824374 --- /dev/null +++ b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeDao.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.foundation.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import ru.pixnews.foundation.database.entity.mode.GameModeEntity + +@Dao +public abstract class GameModeDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + public abstract suspend fun insertSilent(gameMode: GameModeEntity): Long + + @Query("SELECT * FROM `gameMode` WHERE `id` = :id") + public abstract suspend fun getById(id: Long): GameModeEntity? + + @Query("SELECT * FROM `gameMode` WHERE `slug` = :slug") + public abstract suspend fun getBySlug(slug: String): GameModeEntity? + + @Query("SELECT `id` FROM `gameMode` WHERE `slug` = :slug") + public abstract suspend fun getIdBySlug(slug: String): Long? + + public suspend fun insertOrGetId( + gameMode: GameModeEntity, + ): Long { + val id = insertSilent(gameMode) + return if (id == -1L) { + getIdBySlug(gameMode.slug) ?: -1 + } else { + id + } + } +} diff --git a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeNameDao.kt b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeNameDao.kt new file mode 100644 index 00000000..35832f9b --- /dev/null +++ b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/GameModeNameDao.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.foundation.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import ru.pixnews.foundation.database.entity.mode.GameModeNameEntity +import ru.pixnews.foundation.database.model.LanguageCodeWrapper + +@Dao +public abstract class GameModeNameDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + public abstract suspend fun insertGameModeName(gameModeName: GameModeNameEntity): Long + + @Update(onConflict = OnConflictStrategy.IGNORE) + public abstract suspend fun updateGameModeName(gameModeName: GameModeNameEntity) + + @Query("SELECT * FROM `gameModeName` WHERE `id` = :id") + public abstract suspend fun getById(id: Long): GameModeNameEntity? + + @Query( + "SELECT `gameModeName`.* " + + "FROM `gameModeName` " + + "INNER JOIN `gameMode` ON `gameModeName`.gameModeId = `gameMode`.id " + + "WHERE `gameMode`.`slug` = :slug", + ) + public abstract suspend fun getBySlug(slug: String): List + + @Query( + "SELECT gameModeName.* " + + "FROM gameModeName " + + "INNER JOIN gameMode ON gameModeName.gameModeId = gameMode.id " + + "WHERE gameMode.slug = :slug AND gameModeName.languageCode = :language " + + "LIMIT 1", + ) + public abstract suspend fun getBySlugAndLanguage( + slug: String, + language: LanguageCodeWrapper, + ): GameModeNameEntity? + + @Query( + "SELECT * " + + "FROM gameModeName " + + "WHERE gameModeId = :gameModeId AND languageCode = :language", + ) + public abstract suspend fun getByGameIdAndLanguage( + gameModeId: Long, + language: LanguageCodeWrapper, + ): GameModeNameEntity? + + @Transaction + public open suspend fun upsert( + gameModeName: GameModeNameEntity, + ): Long { + val old = getByGameIdAndLanguage(gameModeName.gameModeId, gameModeName.languageCode) + return if (old != null) { + updateGameModeName(gameModeName.copy(id = old.id)) + old.id + } else { + insertGameModeName(gameModeName) + } + } +} diff --git a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/IgdbSyncStatusDao.kt b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/IgdbSyncStatusDao.kt new file mode 100644 index 00000000..658896fa --- /dev/null +++ b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/dao/IgdbSyncStatusDao.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.foundation.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import ru.pixnews.foundation.database.entity.sync.IgdbSyncStatusEntity + +@Dao +public interface IgdbSyncStatusDao { + @Query("SELECT `value` FROM `igdbSyncStatus` WHERE `key` = :key LIMIT 1") + public suspend fun get(key: String): String? + + @Upsert + public suspend fun set(key: IgdbSyncStatusEntity) +} diff --git a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/entity/sync/IgdbSyncStatusEntity.kt b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/entity/sync/IgdbSyncStatusEntity.kt new file mode 100644 index 00000000..61b26ffd --- /dev/null +++ b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/entity/sync/IgdbSyncStatusEntity.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.foundation.database.entity.sync + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity( + tableName = "igdbSyncStatus", +) +public data class IgdbSyncStatusEntity( + @PrimaryKey + val key: String, + val value: String, +) diff --git a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/inject/DatabaseModule.kt b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/inject/DatabaseModule.kt index c0768b40..feb593e8 100644 --- a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/inject/DatabaseModule.kt +++ b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/inject/DatabaseModule.kt @@ -8,10 +8,15 @@ package ru.pixnews.foundation.database.inject import android.content.Context import androidx.room.Room import androidx.room.RoomDatabase.JournalMode.WRITE_AHEAD_LOGGING +import co.touchlab.kermit.Logger import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.optional.SingleIn import dagger.Module +import dagger.Provides +import ru.pixnews.foundation.appconfig.AppConfig +import ru.pixnews.foundation.appconfig.logDatabaseQueries import ru.pixnews.foundation.database.PixnewsDatabase +import ru.pixnews.foundation.database.util.QueryLogger import ru.pixnews.foundation.di.base.qualifiers.ApplicationContext import ru.pixnews.foundation.di.base.scopes.AppScope @@ -19,16 +24,24 @@ import ru.pixnews.foundation.di.base.scopes.AppScope @Module public object DatabaseModule { @SingleIn(AppScope::class) + @Provides public fun providePixnewsDatabase( @ApplicationContext applicationContext: Context, + appConfig: AppConfig, + logger: Logger, ): PixnewsDatabase { - return Room.databaseBuilder( + val builder = Room.databaseBuilder( applicationContext, PixnewsDatabase::class.java, "pixnews", ) .setJournalMode(WRITE_AHEAD_LOGGING) .createFromAsset("pixnews.db") - .build() + + if (appConfig.logDatabaseQueries()) { + builder.setQueryCallback(QueryLogger(logger), QueryLogger.createLoggerExecutor()) + } + + return builder.build() } } diff --git a/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/util/QueryLogger.kt b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/util/QueryLogger.kt new file mode 100644 index 00000000..3923c3e9 --- /dev/null +++ b/foundation/database/src/main/kotlin/ru/pixnews/foundation/database/util/QueryLogger.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024, the Pixnews project authors and contributors. Please see the AUTHORS file for details. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. + */ + +package ru.pixnews.foundation.database.util + +import androidx.room.RoomDatabase +import co.touchlab.kermit.Logger +import java.util.concurrent.Executor + +internal class QueryLogger( + logger: Logger, +) : RoomDatabase.QueryCallback { + private val logger = logger.withTag("Pixnews SQL") + override fun onQuery(sqlQuery: String, bindArgs: List) { + val msg = buildString { + append(sqlQuery) + append(";") + if (bindArgs.isNotEmpty()) { + append(" ARGS: ") + bindArgs.joinTo(this, ", ") { "`$it`" } + } + } + logger.d(msg) + } + + internal companion object { + fun createLoggerExecutor(): Executor = Executor { command -> command.run() } + } +} diff --git a/foundation/domain-model/main/ru/pixnews/domain/model/game/GameMode.kt b/foundation/domain-model/main/ru/pixnews/domain/model/game/GameMode.kt index c8eb5f96..15ccf4c3 100644 --- a/foundation/domain-model/main/ru/pixnews/domain/model/game/GameMode.kt +++ b/foundation/domain-model/main/ru/pixnews/domain/model/game/GameMode.kt @@ -12,12 +12,12 @@ public sealed class GameMode( public open val name: String, public override val id: GameModeId = GameModeId(name), ) : HasId { - public data object SinglePlayer : GameMode("Single Player") - public data object Multiplayer : GameMode("Multiplayer") - public data object SplitScreen : GameMode("Split Screen") - public data object CoOperative : GameMode("Co-operative") - public data object BattleRoyale : GameMode("Battle royale") - public data object Mmo : GameMode("MMO") + public data object SinglePlayer : GameMode("Single Player", GameModeId("single-player")) + public data object Multiplayer : GameMode("Multiplayer", GameModeId("multiplayer")) + public data object SplitScreen : GameMode("Split Screen", GameModeId("split-screen")) + public data object CoOperative : GameMode("Co-operative", GameModeId("co-operative")) + public data object BattleRoyale : GameMode("Battle royale", GameModeId("battle-royale")) + public data object Mmo : GameMode("MMO", GameModeId("massively-multiplayer-online-mmo")) public data class Other( override val name: String, public override val id: GameModeId = GameModeId(name), diff --git a/foundation/domain-model/main/ru/pixnews/domain/model/id/GameModeId.kt b/foundation/domain-model/main/ru/pixnews/domain/model/id/GameModeId.kt index d68d7d75..464f0ef6 100644 --- a/foundation/domain-model/main/ru/pixnews/domain/model/id/GameModeId.kt +++ b/foundation/domain-model/main/ru/pixnews/domain/model/id/GameModeId.kt @@ -14,4 +14,6 @@ public interface GameModeId : ExternalId { @JvmInline public value class DefaultGameModeId( public val id: String, -) : GameModeId +) : GameModeId { + override fun toString(): String = id +}