Cache achievement data to reduce the number of requests

This commit is contained in:
Rafael Caetano 2023-02-11 18:00:57 +00:00
parent f334cf39be
commit 0eb2bdfea9
12 changed files with 289 additions and 30 deletions

View File

@ -132,6 +132,7 @@ dependencies {
implementation(preference)
implementation(recyclerView)
implementation(room)
implementation(roomKtx)
implementation(roomRxJava)
implementation(splashscreen)
implementation(swipeRefreshLayout)

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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<RAAchievementEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateGameAchievements(achievements: List<RAAchievementEntity>)
@Query("SELECT * FROM ra_user_achievement WHERE game_id = :gameId AND is_unlocked = 1")
suspend fun getGameUserUnlockedAchievements(gameId: Long): List<RAUserAchievementEntity>
@Query("DELETE FROM ra_user_achievement WHERE game_id = :gameId")
suspend fun deleteGameUserUnlockedAchievements(gameId: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGameUserUnlockedAchievements(userAchievements: List<RAUserAchievementEntity>)
@Transaction
suspend fun updateGameUserUnlockedAchievements(gameId: Long, userAchievements: List<RAUserAchievementEntity>) {
deleteGameUserUnlockedAchievements(gameId)
insertGameUserUnlockedAchievements(userAchievements)
}
}

View File

@ -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,
)

View File

@ -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?,
)

View File

@ -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,
)

View File

@ -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

View File

@ -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<List<RAUserAchievement>> {
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<List<RAAchievement>>).getOrThrow()
@Suppress("UNCHECKED_CAST")
val userUnlocks = (userUnlocksResult as Result<List<Long>>).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<List<RAAchievement>> {
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<List<Long>> {
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
}
}
}
}

View File

@ -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],
)
}

View File

@ -3,4 +3,11 @@
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">nus.cdn.t.shop.nintendowifi.net</domain>
</domain-config>
<debug-overrides>
<trust-anchors>
<certificates src="user" />
<certificates src="system" />
</trust-anchors>
</debug-overrides>
</network-security-config>

View File

@ -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}"