From 0eb2bdfea9368f490551c07fc169763228d700fe Mon Sep 17 00:00:00 2001 From: Rafael Caetano Date: Sat, 11 Feb 2023 18:00:57 +0000 Subject: [PATCH] Cache achievement data to reduce the number of requests --- app/build.gradle.kts | 1 + .../magnum/melonds/database/MelonDatabase.kt | 28 +++- .../database/converters/InstantConverter.kt | 19 +++ .../database/daos/RAAchievementsDao.kt | 37 ++++++ .../retroachievements/RAAchievementEntity.kt | 27 ++++ .../retroachievements/RAGameSetMetadata.kt | 13 ++ .../RAUserAchievementEntity.kt | 23 ++++ .../java/me/magnum/melonds/di/MelonModule.kt | 4 +- .../AndroidRetroAchievementsRepository.kt | 120 ++++++++++++++---- .../retroachievements/RAAchievementMapper.kt | 39 ++++++ .../main/res/xml/network_security_config.xml | 7 + buildSrc/src/main/kotlin/Dependencies.kt | 1 + 12 files changed, 289 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/me/magnum/melonds/database/converters/InstantConverter.kt create mode 100644 app/src/main/java/me/magnum/melonds/database/daos/RAAchievementsDao.kt create mode 100644 app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAAchievementEntity.kt create mode 100644 app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAGameSetMetadata.kt create mode 100644 app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAUserAchievementEntity.kt create mode 100644 app/src/main/java/me/magnum/melonds/impl/mappers/retroachievements/RAAchievementMapper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0f8dc53..2de99b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -132,6 +132,7 @@ dependencies { implementation(preference) implementation(recyclerView) implementation(room) + implementation(roomKtx) implementation(roomRxJava) implementation(splashscreen) implementation(swipeRefreshLayout) diff --git a/app/src/main/java/me/magnum/melonds/database/MelonDatabase.kt b/app/src/main/java/me/magnum/melonds/database/MelonDatabase.kt index 69f1b51..65cb5ef 100644 --- a/app/src/main/java/me/magnum/melonds/database/MelonDatabase.kt +++ b/app/src/main/java/me/magnum/melonds/database/MelonDatabase.kt @@ -5,34 +5,50 @@ import android.database.sqlite.SQLiteDatabase import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.TypeConverters import androidx.room.migration.AutoMigrationSpec import androidx.sqlite.db.SupportSQLiteDatabase -import me.magnum.melonds.database.daos.CheatFolderDao -import me.magnum.melonds.database.daos.CheatDao -import me.magnum.melonds.database.daos.CheatDatabaseDao -import me.magnum.melonds.database.daos.GameDao +import me.magnum.melonds.database.converters.InstantConverter +import me.magnum.melonds.database.daos.* import me.magnum.melonds.database.entities.CheatDatabaseEntity import me.magnum.melonds.database.entities.CheatEntity import me.magnum.melonds.database.entities.CheatFolderEntity import me.magnum.melonds.database.entities.GameEntity +import me.magnum.melonds.database.entities.retroachievements.RAAchievementEntity +import me.magnum.melonds.database.entities.retroachievements.RAGameSetMetadata +import me.magnum.melonds.database.entities.retroachievements.RAUserAchievementEntity @Database( - version = 3, + version = 4, exportSchema = true, - entities = [CheatDatabaseEntity::class, GameEntity::class, CheatFolderEntity::class, CheatEntity::class], + entities = [ + CheatDatabaseEntity::class, + GameEntity::class, + CheatFolderEntity::class, + CheatEntity::class, + RAAchievementEntity::class, + RAUserAchievementEntity::class, + RAGameSetMetadata::class, + ], autoMigrations = [ AutoMigration( from = 2, to = 3, spec = MelonDatabase.Migration2to3Spec::class, + ), + AutoMigration( + from = 3, + to = 4, ) ] ) +@TypeConverters(InstantConverter::class) abstract class MelonDatabase : RoomDatabase() { abstract fun cheatDatabaseDao(): CheatDatabaseDao abstract fun gameDao(): GameDao abstract fun cheatFolderDao(): CheatFolderDao abstract fun cheatDao(): CheatDao + abstract fun achievementsDao(): RAAchievementsDao class Migration2to3Spec : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { diff --git a/app/src/main/java/me/magnum/melonds/database/converters/InstantConverter.kt b/app/src/main/java/me/magnum/melonds/database/converters/InstantConverter.kt new file mode 100644 index 0000000..6c4b9c0 --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/database/converters/InstantConverter.kt @@ -0,0 +1,19 @@ +package me.magnum.melonds.database.converters + +import androidx.room.TypeConverter +import java.time.Instant + +class InstantConverter { + + @TypeConverter + fun instantToTimestamp(instant: Instant?): Long? { + return instant?.toEpochMilli() + } + + @TypeConverter + fun timestampToInstant(timestamp: Long?): Instant? { + return timestamp?.let { + Instant.ofEpochMilli(it) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/database/daos/RAAchievementsDao.kt b/app/src/main/java/me/magnum/melonds/database/daos/RAAchievementsDao.kt new file mode 100644 index 0000000..5b746b3 --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/database/daos/RAAchievementsDao.kt @@ -0,0 +1,37 @@ +package me.magnum.melonds.database.daos + +import androidx.room.* +import me.magnum.melonds.database.entities.retroachievements.RAAchievementEntity +import me.magnum.melonds.database.entities.retroachievements.RAGameSetMetadata +import me.magnum.melonds.database.entities.retroachievements.RAUserAchievementEntity + +@Dao +interface RAAchievementsDao { + + @Query("SELECT * FROM ra_game_set_metadata WHERE game_id = :gameId") + suspend fun getGameSetMetadata(gameId: Long): RAGameSetMetadata? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateGameSetMetadata(gameSetMetadata: RAGameSetMetadata) + + @Query("SELECT * FROM ra_achievement WHERE game_id = :gameId") + suspend fun getGameAchievements(gameId: Long): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateGameAchievements(achievements: List) + + @Query("SELECT * FROM ra_user_achievement WHERE game_id = :gameId AND is_unlocked = 1") + suspend fun getGameUserUnlockedAchievements(gameId: Long): List + + @Query("DELETE FROM ra_user_achievement WHERE game_id = :gameId") + suspend fun deleteGameUserUnlockedAchievements(gameId: Long) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertGameUserUnlockedAchievements(userAchievements: List) + + @Transaction + suspend fun updateGameUserUnlockedAchievements(gameId: Long, userAchievements: List) { + deleteGameUserUnlockedAchievements(gameId) + insertGameUserUnlockedAchievements(userAchievements) + } +} \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAAchievementEntity.kt b/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAAchievementEntity.kt new file mode 100644 index 0000000..32a8891 --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAAchievementEntity.kt @@ -0,0 +1,27 @@ +package me.magnum.melonds.database.entities.retroachievements + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "ra_achievement", + indices = [ + Index("game_id") + ] +) +data class RAAchievementEntity( + @PrimaryKey @ColumnInfo(name = "id") val id: Long, + @ColumnInfo(name = "game_id") val gameId: Long, + @ColumnInfo(name = "total_awards_casual") val totalAwardsCasual: Int, + @ColumnInfo(name = "total_awards_hardcore") val totalAwardsHardcore: Int, + @ColumnInfo(name = "title") val title: String, + @ColumnInfo(name = "description") val description: String, + @ColumnInfo(name = "points") val points: Int, + @ColumnInfo(name = "display_order") val displayOrder: Int, + @ColumnInfo(name = "badge_url_unlocked") val badgeUrlUnlocked: String, + @ColumnInfo(name = "badge_url_locked") val badgeUrlLocked: String, + @ColumnInfo(name = "memory_address") val memoryAddress: String, + @ColumnInfo(name = "type") val type: Int, +) \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAGameSetMetadata.kt b/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAGameSetMetadata.kt new file mode 100644 index 0000000..8877631 --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAGameSetMetadata.kt @@ -0,0 +1,13 @@ +package me.magnum.melonds.database.entities.retroachievements + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.time.Instant + +@Entity(tableName = "ra_game_set_metadata") +data class RAGameSetMetadata( + @PrimaryKey @ColumnInfo(name = "game_id") val gameId: Long, + @ColumnInfo(name = "last_achievement_set_updated") val lastAchievementSetUpdated: Instant?, + @ColumnInfo(name = "last_user_data_updated") val lastUserDataUpdated: Instant?, +) diff --git a/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAUserAchievementEntity.kt b/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAUserAchievementEntity.kt new file mode 100644 index 0000000..0a8187b --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/database/entities/retroachievements/RAUserAchievementEntity.kt @@ -0,0 +1,23 @@ +package me.magnum.melonds.database.entities.retroachievements + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + tableName = "ra_user_achievement", + primaryKeys = ["game_id", "achievement_id"], + foreignKeys = [ + ForeignKey( + entity = RAAchievementEntity::class, + parentColumns = ["id"], + childColumns = ["achievement_id"], + onDelete = ForeignKey.CASCADE, + ) + ], +) +data class RAUserAchievementEntity( + @ColumnInfo(name = "game_id") val gameId: Long, + @ColumnInfo(name = "achievement_id") val achievementId: Long, + @ColumnInfo(name = "is_unlocked") val isUnlocked: Boolean, +) diff --git a/app/src/main/java/me/magnum/melonds/di/MelonModule.kt b/app/src/main/java/me/magnum/melonds/di/MelonModule.kt index 1299726..fa23571 100644 --- a/app/src/main/java/me/magnum/melonds/di/MelonModule.kt +++ b/app/src/main/java/me/magnum/melonds/di/MelonModule.kt @@ -83,8 +83,8 @@ object MelonModule { @Provides @Singleton - fun provideRetroAchievementsRepository(raApi: RAApi, raUserAuthStore: RAUserAuthStore): RetroAchievementsRepository { - return AndroidRetroAchievementsRepository(raApi, raUserAuthStore) + fun provideRetroAchievementsRepository(raApi: RAApi, melonDatabase: MelonDatabase, raUserAuthStore: RAUserAuthStore): RetroAchievementsRepository { + return AndroidRetroAchievementsRepository(raApi, melonDatabase.achievementsDao(), raUserAuthStore) } @Provides diff --git a/app/src/main/java/me/magnum/melonds/impl/AndroidRetroAchievementsRepository.kt b/app/src/main/java/me/magnum/melonds/impl/AndroidRetroAchievementsRepository.kt index 515989b..cdbbf4b 100644 --- a/app/src/main/java/me/magnum/melonds/impl/AndroidRetroAchievementsRepository.kt +++ b/app/src/main/java/me/magnum/melonds/impl/AndroidRetroAchievementsRepository.kt @@ -1,17 +1,22 @@ package me.magnum.melonds.impl -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope +import me.magnum.melonds.database.daos.RAAchievementsDao +import me.magnum.melonds.database.entities.retroachievements.RAGameSetMetadata +import me.magnum.melonds.database.entities.retroachievements.RAUserAchievementEntity import me.magnum.melonds.domain.model.retroachievements.RAUserAchievement import me.magnum.melonds.domain.repositories.RetroAchievementsRepository +import me.magnum.melonds.impl.mappers.retroachievements.mapToEntity +import me.magnum.melonds.impl.mappers.retroachievements.mapToModel import me.magnum.rcheevosapi.RAApi import me.magnum.rcheevosapi.RAUserAuthStore import me.magnum.rcheevosapi.model.RAAchievement import me.magnum.rcheevosapi.model.RAGameId +import java.time.Duration +import java.time.Instant class AndroidRetroAchievementsRepository( private val raApi: RAApi, + private val achievementsDao: RAAchievementsDao, private val raUserAuthStore: RAUserAuthStore, ) : RetroAchievementsRepository { @@ -24,33 +29,21 @@ class AndroidRetroAchievementsRepository( } override suspend fun getGameUserAchievements(gameId: RAGameId): Result> { - val (gameAchievementsResult, userUnlocksResult) = coroutineScope { - awaitAll( - async { - raApi.getGameInfo(gameId).map { game -> - game.achievements - .filter { - it.type == RAAchievement.Type.CORE - } - } - }, - async { - raApi.getUserUnlockedAchievements(gameId, false) - }, - ) - } + val gameSetMetadata = achievementsDao.getGameSetMetadata(gameId.id) + val currentMetadata = CurrentGameSetMetadata(gameId, gameSetMetadata) + val gameAchievementsResult = fetchGameAchievements(gameId, currentMetadata) if (gameAchievementsResult.isFailure) { return Result.failure(gameAchievementsResult.exceptionOrNull()!!) } + + val userUnlocksResult = fetchGameUserUnlockedAchievements(gameId, currentMetadata) if (userUnlocksResult.isFailure) { return Result.failure(userUnlocksResult.exceptionOrNull()!!) } - @Suppress("UNCHECKED_CAST") - val gameAchievements = (gameAchievementsResult as Result>).getOrThrow() - @Suppress("UNCHECKED_CAST") - val userUnlocks = (userUnlocksResult as Result>).getOrThrow() + val gameAchievements = gameAchievementsResult.getOrThrow() + val userUnlocks = userUnlocksResult.getOrThrow() val userAchievements = gameAchievements.map { RAUserAchievement( @@ -60,4 +53,87 @@ class AndroidRetroAchievementsRepository( } return Result.success(userAchievements) } + + private suspend fun fetchGameAchievements(gameId: RAGameId, gameSetMetadata: CurrentGameSetMetadata): Result> { + return if (mustRefreshAchievementSet(gameSetMetadata.currentMetadata)) { + raApi.getGameInfo(gameId).map { game -> + game.achievements + }.onSuccess { achievements -> + val achievementEntities = achievements.map { + it.mapToEntity(gameId) + } + + val newMetadata = gameSetMetadata.withNewAchievementSetUpdate() + achievementsDao.updateGameAchievements(achievementEntities) + achievementsDao.updateGameSetMetadata(newMetadata) + } + } else { + runCatching { + achievementsDao.getGameAchievements(gameId.id).map { + it.mapToModel() + } + } + }.map { achievements -> + achievements.filter { it.type == RAAchievement.Type.CORE } + } + } + + private suspend fun fetchGameUserUnlockedAchievements(gameId: RAGameId, gameSetMetadata: CurrentGameSetMetadata): Result> { + return if (mustRefreshUserData(gameSetMetadata.currentMetadata)) { + raApi.getUserUnlockedAchievements(gameId, false).onSuccess { userUnlocks -> + val userAchievementEntities = userUnlocks.map { + RAUserAchievementEntity( + gameId.id, + it, + true, + ) + } + + val newMetadata = gameSetMetadata.withNewUserAchievementsUpdate() + achievementsDao.updateGameUserUnlockedAchievements(gameId.id, userAchievementEntities) + achievementsDao.updateGameSetMetadata(newMetadata) + } + } else { + runCatching { + achievementsDao.getGameUserUnlockedAchievements(gameId.id).map { + it.achievementId + } + } + } + } + + private fun mustRefreshAchievementSet(gameSetMetadata: RAGameSetMetadata?): Boolean { + if (gameSetMetadata?.lastAchievementSetUpdated == null) { + return true + } + + // Update the achievement set once a week + return Duration.between(gameSetMetadata.lastAchievementSetUpdated, Instant.now()) >= Duration.ofDays(7) + } + + private fun mustRefreshUserData(gameSetMetadata: RAGameSetMetadata?): Boolean { + if (gameSetMetadata?.lastUserDataUpdated == null) { + return true + } + + // Sync user achievement data once a day + return Duration.between(gameSetMetadata.lastUserDataUpdated, Instant.now()) >= Duration.ofDays(1) + } + + private class CurrentGameSetMetadata(private val gameId: RAGameId, initialMetadata: RAGameSetMetadata?) { + var currentMetadata = initialMetadata + private set + + fun withNewAchievementSetUpdate(): RAGameSetMetadata { + return (currentMetadata?.copy(lastAchievementSetUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, Instant.now(), null)).also { + currentMetadata = it + } + } + + fun withNewUserAchievementsUpdate(): RAGameSetMetadata { + return (currentMetadata?.copy(lastUserDataUpdated = Instant.now()) ?: RAGameSetMetadata(gameId.id, null, Instant.now())).also { + currentMetadata = it + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/me/magnum/melonds/impl/mappers/retroachievements/RAAchievementMapper.kt b/app/src/main/java/me/magnum/melonds/impl/mappers/retroachievements/RAAchievementMapper.kt new file mode 100644 index 0000000..fd3b70a --- /dev/null +++ b/app/src/main/java/me/magnum/melonds/impl/mappers/retroachievements/RAAchievementMapper.kt @@ -0,0 +1,39 @@ +package me.magnum.melonds.impl.mappers.retroachievements + +import me.magnum.melonds.database.entities.retroachievements.RAAchievementEntity +import me.magnum.rcheevosapi.model.RAAchievement +import me.magnum.rcheevosapi.model.RAGameId +import java.net.URL + +fun RAAchievement.mapToEntity(gameId: RAGameId): RAAchievementEntity { + return RAAchievementEntity( + id, + gameId.id, + totalAwardsCasual, + totalAwardsHardcore, + title, + description, + points, + displayOrder, + badgeUrlUnlocked.toString(), + badgeUrlLocked.toString(), + memoryAddress, + type.ordinal, + ) +} + +fun RAAchievementEntity.mapToModel(): RAAchievement { + return RAAchievement( + id, + totalAwardsCasual, + totalAwardsHardcore, + title, + description, + points, + displayOrder, + URL(badgeUrlUnlocked), + URL(badgeUrlLocked), + memoryAddress, + RAAchievement.Type.values()[type], + ) +} \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index d107a27..b66aff8 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -3,4 +3,11 @@ nus.cdn.t.shop.nintendowifi.net + + + + + + + diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index befec95..ec62f34 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -66,6 +66,7 @@ object Dependencies { const val preference = "androidx.preference:preference-ktx:${Versions.Preference}" const val recyclerView = "androidx.recyclerview:recyclerview:${Versions.RecyclerView}" const val room = "androidx.room:room-runtime:${Versions.Room}" + const val roomKtx = "androidx.room:room-ktx:${Versions.Room}" const val roomRxJava = "androidx.room:room-rxjava2:${Versions.Room}" const val splashscreen = "androidx.core:core-splashscreen:${Versions.Splashscreen}" const val swipeRefreshLayout = "androidx.swiperefreshlayout:swiperefreshlayout:${Versions.SwipeRefreshLayout}"