From 34964f7b8cf6ba3cccbde9ad6e40594f46bd47de Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Wed, 18 Feb 2026 15:08:08 -0500 Subject: [PATCH] feat: Introduce local link data access with `LinkDao` and `SyncManager` for synchronization. --- .../com/shaarit/data/local/dao/LinkDao.kt | 6 ++ .../java/com/shaarit/data/sync/SyncManager.kt | 71 ++++++++++++------- version.properties | 6 +- 3 files changed, 54 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt index 1a1588d..627e75a 100644 --- a/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt +++ b/app/src/main/java/com/shaarit/data/local/dao/LinkDao.kt @@ -178,6 +178,12 @@ interface LinkDao { @Query("UPDATE links SET sync_status = 'SYNCED', local_modified_at = :timestamp WHERE id = :id") suspend fun markAsSynced(id: Int, timestamp: Long = System.currentTimeMillis()) + @Query("SELECT id FROM links WHERE sync_status = 'SYNCED'") + suspend fun getAllSyncedLinkIds(): List + + @Query("DELETE FROM links WHERE id IN (:ids) AND sync_status = 'SYNCED'") + suspend fun deleteSyncedLinksByIds(ids: List) + // ====== Insert / Update / Delete ====== @Insert(onConflict = OnConflictStrategy.REPLACE) diff --git a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt index a1fabc3..ae612bb 100644 --- a/app/src/main/java/com/shaarit/data/sync/SyncManager.kt +++ b/app/src/main/java/com/shaarit/data/sync/SyncManager.kt @@ -371,7 +371,14 @@ class SyncManager @Inject constructor( api.deleteLink(link.id) linkDao.deleteLink(link.id) } 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) { 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) - * S'arrête quand on rencontre des liens déjà synchronisés (non modifiés depuis la dernière sync) + * Récupère les données depuis le serveur (sync complète). + * 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() { var offset = 0 val limit = 100 var hasMore = true - val lastSyncTimestamp = tokenManager.getLastSyncTimestamp() 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() + + Log.d(TAG, "Démarrage du pull complet depuis le serveur") while (hasMore) { try { @@ -405,7 +412,7 @@ class SyncManager @Inject constructor( if (links.isEmpty()) { hasMore = false } 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 isValid = dto.id != null && !dto.url.isNullOrBlank() val isCollectionsConfig = @@ -415,6 +422,9 @@ class SyncManager @Inject constructor( } 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 val entities = validLinks.mapNotNull { dto -> @@ -427,9 +437,8 @@ class SyncManager @Inject constructor( return@mapNotNull null } - // Check if this link has been modified since last sync - if (!isFirstSync && existing != null && existing.updatedAt >= serverUpdatedAt && existing.syncStatus == SyncStatus.SYNCED) { - // Link unchanged since last sync — skip + // Si le lien existe déjà et n'a pas changé, on le skip + if (existing != null && serverUpdatedAt > 0L && existing.updatedAt == serverUpdatedAt) { return@mapNotNull null } @@ -444,7 +453,7 @@ class SyncManager @Inject constructor( isPrivate = dto.isPrivate ?: false, isPinned = existing?.isPinned ?: false, createdAt = parseDate(dto.created), - updatedAt = serverUpdatedAt, + updatedAt = if (serverUpdatedAt > 0L) serverUpdatedAt else existing?.updatedAt ?: syncStartTime, syncStatus = SyncStatus.SYNCED, thumbnailUrl = existing?.thumbnailUrl, 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") - // 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 } } 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 tokenManager.saveLastSyncTimestamp(syncStartTime) @@ -590,11 +603,17 @@ class SyncManager @Inject constructor( } private fun parseDate(dateString: String?): Long { - if (dateString.isNullOrBlank()) return System.currentTimeMillis() + if (dateString.isNullOrBlank()) return 0L return try { java.time.Instant.parse(dateString).toEpochMilli() } 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 + } } } diff --git a/version.properties b/version.properties index 2f994a7..d1164b1 100644 --- a/version.properties +++ b/version.properties @@ -1,3 +1,3 @@ -#Sat Feb 14 15:58:10 2026 -VERSION_NAME=2.1.7 -VERSION_CODE=22 \ No newline at end of file +#Sun Feb 15 10:38:54 2026 +VERSION_NAME=2.1.8 +VERSION_CODE=23 \ No newline at end of file