feat: Introduce local link data access with LinkDao and SyncManager for synchronization.

This commit is contained in:
Bruno Charest 2026-02-18 15:08:08 -05:00
parent 2358ed68b2
commit 34964f7b8c
3 changed files with 54 additions and 29 deletions

View File

@ -178,6 +178,12 @@ interface LinkDao {
@Query("UPDATE links SET sync_status = 'SYNCED', local_modified_at = :timestamp WHERE id = :id") @Query("UPDATE links SET sync_status = 'SYNCED', local_modified_at = :timestamp WHERE id = :id")
suspend fun markAsSynced(id: Int, timestamp: Long = System.currentTimeMillis()) suspend fun markAsSynced(id: Int, timestamp: Long = System.currentTimeMillis())
@Query("SELECT id FROM links WHERE sync_status = 'SYNCED'")
suspend fun getAllSyncedLinkIds(): List<Int>
@Query("DELETE FROM links WHERE id IN (:ids) AND sync_status = 'SYNCED'")
suspend fun deleteSyncedLinksByIds(ids: List<Int>)
// ====== Insert / Update / Delete ====== // ====== Insert / Update / Delete ======
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)

View File

@ -371,7 +371,14 @@ class SyncManager @Inject constructor(
api.deleteLink(link.id) api.deleteLink(link.id)
linkDao.deleteLink(link.id) linkDao.deleteLink(link.id)
} catch (e: HttpException) { } catch (e: HttpException) {
Log.e(TAG, "Échec suppression lien ${link.id}: ${e.code()}", e) val code = e.code()
if (code == 404) {
// Lien déjà supprimé sur le serveur — nettoyer localement
linkDao.deleteLink(link.id)
Log.d(TAG, "Lien ${link.id} déjà supprimé sur serveur (404), nettoyé localement")
} else {
Log.e(TAG, "Échec suppression lien ${link.id}: $code", e)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e) Log.e(TAG, "Exception lors de la suppression du lien ${link.id}", e)
} }
@ -382,20 +389,20 @@ class SyncManager @Inject constructor(
} }
/** /**
* Récupère les données depuis le serveur (sync incrémentale) * Récupère les données depuis le serveur (sync complète).
* S'arrête quand on rencontre des liens déjà synchronisés (non modifiés depuis la dernière sync) * Parcourt TOUTES les pages du serveur, met à jour les liens locaux,
* et supprime les liens locaux SYNCED qui n'existent plus sur le serveur.
*/ */
private suspend fun pullFromServer() { private suspend fun pullFromServer() {
var offset = 0 var offset = 0
val limit = 100 val limit = 100
var hasMore = true var hasMore = true
val lastSyncTimestamp = tokenManager.getLastSyncTimestamp()
val syncStartTime = System.currentTimeMillis() val syncStartTime = System.currentTimeMillis()
val isFirstSync = lastSyncTimestamp == 0L
var unchangedStreakCount = 0
val unchangedStreakThreshold = 2 // Stop after 2 consecutive pages of unchanged links
Log.d(TAG, "Sync incrémentale: lastSync=${if (isFirstSync) "jamais" else java.time.Instant.ofEpochMilli(lastSyncTimestamp)}") // Collecter tous les IDs vus sur le serveur pour détecter les suppressions côté serveur
val serverIds = mutableSetOf<Int>()
Log.d(TAG, "Démarrage du pull complet depuis le serveur")
while (hasMore) { while (hasMore) {
try { try {
@ -405,7 +412,7 @@ class SyncManager @Inject constructor(
if (links.isEmpty()) { if (links.isEmpty()) {
hasMore = false hasMore = false
} else { } else {
// Filtrer les liens invalides (sans ID ou URL) et convertir en entités // Filtrer les liens invalides (sans ID ou URL) et exclure la config collections
val validLinks = links.filter { dto -> val validLinks = links.filter { dto ->
val isValid = dto.id != null && !dto.url.isNullOrBlank() val isValid = dto.id != null && !dto.url.isNullOrBlank()
val isCollectionsConfig = val isCollectionsConfig =
@ -415,6 +422,9 @@ class SyncManager @Inject constructor(
} }
Log.d(TAG, "${validLinks.size}/${links.size} liens valides") Log.d(TAG, "${validLinks.size}/${links.size} liens valides")
// Enregistrer tous les IDs serveur
validLinks.forEach { dto -> dto.id?.let { serverIds.add(it) } }
var newOrUpdatedCount = 0 var newOrUpdatedCount = 0
val entities = validLinks.mapNotNull { dto -> val entities = validLinks.mapNotNull { dto ->
@ -427,9 +437,8 @@ class SyncManager @Inject constructor(
return@mapNotNull null return@mapNotNull null
} }
// Check if this link has been modified since last sync // Si le lien existe déjà et n'a pas changé, on le skip
if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) { if (existing != null && serverUpdatedAt > 0L && existing.updatedAt == serverUpdatedAt) {
// Link unchanged since last sync — skip
return@mapNotNull null return@mapNotNull null
} }
@ -444,7 +453,7 @@ class SyncManager @Inject constructor(
isPrivate = dto.isPrivate ?: false, isPrivate = dto.isPrivate ?: false,
isPinned = existing?.isPinned ?: false, isPinned = existing?.isPinned ?: false,
createdAt = parseDate(dto.created), createdAt = parseDate(dto.created),
updatedAt = serverUpdatedAt, updatedAt = if (serverUpdatedAt > 0L) serverUpdatedAt else existing?.updatedAt ?: syncStartTime,
syncStatus = SyncStatus.SYNCED, syncStatus = SyncStatus.SYNCED,
thumbnailUrl = existing?.thumbnailUrl, thumbnailUrl = existing?.thumbnailUrl,
readingTimeMinutes = existing?.readingTimeMinutes, readingTimeMinutes = existing?.readingTimeMinutes,
@ -469,17 +478,6 @@ class SyncManager @Inject constructor(
Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides") Log.d(TAG, "Page offset=$offset: $newOrUpdatedCount nouveaux/modifiés sur ${validLinks.size} valides")
// Incremental sync: stop early if we encounter pages with no changes
if (!isFirstSync && newOrUpdatedCount == 0) {
unchangedStreakCount++
if (unchangedStreakCount >= unchangedStreakThreshold) {
Log.d(TAG, "Sync incrémentale: $unchangedStreakThreshold pages consécutives sans changement, arrêt anticipé")
hasMore = false
}
} else {
unchangedStreakCount = 0
}
offset += links.size offset += links.size
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -488,6 +486,21 @@ class SyncManager @Inject constructor(
} }
} }
// Détecter et supprimer les liens locaux SYNCED qui n'existent plus sur le serveur
try {
val localSyncedIds = linkDao.getAllSyncedLinkIds()
val deletedOnServer = localSyncedIds.filter { it !in serverIds }
if (deletedOnServer.isNotEmpty()) {
Log.d(TAG, "${deletedOnServer.size} liens supprimés côté serveur, nettoyage local")
// Supprimer par lots pour éviter les limites SQLite
deletedOnServer.chunked(500).forEach { batch ->
linkDao.deleteSyncedLinksByIds(batch)
}
}
} catch (e: Exception) {
Log.e(TAG, "Erreur lors de la détection des suppressions serveur", e)
}
// Save sync timestamp on success // Save sync timestamp on success
tokenManager.saveLastSyncTimestamp(syncStartTime) tokenManager.saveLastSyncTimestamp(syncStartTime)
@ -590,11 +603,17 @@ class SyncManager @Inject constructor(
} }
private fun parseDate(dateString: String?): Long { private fun parseDate(dateString: String?): Long {
if (dateString.isNullOrBlank()) return System.currentTimeMillis() if (dateString.isNullOrBlank()) return 0L
return try { return try {
java.time.Instant.parse(dateString).toEpochMilli() java.time.Instant.parse(dateString).toEpochMilli()
} catch (e: Exception) { } catch (e: Exception) {
System.currentTimeMillis() try {
// Fallback: essayer le format ISO sans 'Z' (ex: "2024-01-15T10:30:00+01:00")
java.time.OffsetDateTime.parse(dateString).toInstant().toEpochMilli()
} catch (e2: Exception) {
Log.w(TAG, "Impossible de parser la date: $dateString")
0L
}
} }
} }

View File

@ -1,3 +1,3 @@
#Sat Feb 14 15:58:10 2026 #Sun Feb 15 10:38:54 2026
VERSION_NAME=2.1.7 VERSION_NAME=2.1.8
VERSION_CODE=22 VERSION_CODE=23