feat: Introduce local link data access with LinkDao and SyncManager for synchronization.
This commit is contained in:
parent
2358ed68b2
commit
34964f7b8c
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user