From 5c8375be707b72297fa972e070c459f2a4027017 Mon Sep 17 00:00:00 2001 From: Abdel-Hamid Makarimi Date: Mon, 2 Nov 2026 15:26:05 +0100 Subject: [PATCH] =?UTF-8?q?=C3=89carte=20la=20m=C3=AAme=20s=C3=A9ance=20re?= =?UTF-8?q?lay=C3=A9e=20par=20une=20autre=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une séance faite dans Lyfta part sur Strava, qui l'écrit à son tour dans Health Connect : l'historique la montrait deux fois. Deux enregistrements qui se chevauchent dans le temps décrivent le même entraînement. Seul celui de l'application la mieux placée est gardé — Lyfta avant Strava, l'application où la séance a été saisie étant la plus fidèle — et à défaut le plus long, qui porte en général le plus de détail. Un léger décalage de créneau, fréquent après un relais, ne trompe pas la détection. Deux séances distinctes restent toutes les deux : une course Strava sans équivalent Lyfta n'est pas écartée. Les doublons déjà importés sont retirés au passage : une séance importée qui n'est plus dans la liste n'a plus lieu d'être, qu'elle ait été supprimée à la source ou qu'elle fût le doublon. Les séances saisies dans FitTrack n'ont pas d'identifiant externe et ne sont jamais touchées. --- README.md | 4 +- .../java/com/kaz229/fittrack/data/Daos.kt | 4 + .../fittrack/data/FitTrackRepository.kt | 14 ++- .../com/kaz229/fittrack/data/HealthConnect.kt | 49 ++++++++ .../ui/screens/HealthConnectScreen.kt | 4 +- .../com/kaz229/fittrack/ForeignSessionTest.kt | 117 ++++++++++++++++++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/kaz229/fittrack/ForeignSessionTest.kt diff --git a/README.md b/README.md index f1e9510..c198f1a 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,9 @@ Rien d'autre ne sort du téléphone, et les deux restent facultatifs. - **Séance oubliée** : au bout de 10 minutes sans nouvelle série, l'app demande si tu es toujours là — une séance laissée ouverte fausse sa durée et ses calories. - **Séances venues d'ailleurs** : celles enregistrées dans une autre application (Lyfta, une - montre) sont reprises depuis Health Connect, sans doublon. + montre) sont reprises depuis Health Connect. Quand la même séance arrive deux fois — Lyfta + la relaie sur Strava, qui l'écrit à son tour — seule celle de l'application où elle a été + saisie est gardée. - **Repas au code-barres** : on scanne le produit, l'app récupère ses valeurs nutritionnelles chez Open Food Facts, on saisit la quantité en grammes et l'apport est calculé au prorata — calories, protéines, glucides, lipides. diff --git a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt index bf5a490..1fb9e02 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/Daos.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/Daos.kt @@ -40,6 +40,10 @@ interface SessionDao { @Query("SELECT externalId FROM sessions WHERE externalId IS NOT NULL") suspend fun importedExternalIds(): List + /** Les séances importées d'une période, pour repérer celles qui n'ont plus lieu d'être. */ + @Query("SELECT * FROM sessions WHERE externalId IS NOT NULL AND epochDay >= :fromDay") + suspend fun importedSince(fromDay: Long): List + /** Réécrit les séances d'une restauration, identifiants d'origine compris. */ @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(sessions: List) diff --git a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt index 348eb5e..3979db1 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt @@ -398,10 +398,22 @@ class FitTrackRepository( */ suspend fun importHealthSessions(today: LocalDate = LocalDate.now()): Int { val manager = healthConnect ?: return 0 + val foreign = manager.foreignSessions(today, SYNC_DAYS) + + // Une séance importée qui n'est plus dans la liste n'a plus lieu d'être : soit + // elle a été supprimée à la source, soit c'était le doublon relayé par une autre + // application. Les séances saisies ici n'ont pas d'identifiant externe et ne sont + // jamais touchées. + val fromDay = today.minusDays((SYNC_DAYS - 1).toLong()).toEpochDay() + val stillThere = foreign.map { it.id }.toSet() + for (session in sessionDao.importedSince(fromDay)) { + if (session.externalId !in stillThere) sessionDao.delete(session) + } + val known = sessionDao.importedExternalIds().toSet() val weight = userPreferences.profile.first().weightKg var imported = 0 - for (session in manager.foreignSessions(today, SYNC_DAYS)) { + for (session in foreign) { if (session.id in known) continue val activity = if (session.isStrength) { Activities.strengthTraining diff --git a/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt b/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt index 0682d85..039f797 100644 --- a/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt +++ b/app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt @@ -45,11 +45,22 @@ data class ForeignSession( val start: java.time.Instant, val minutes: Int, val day: LocalDate, + val end: java.time.Instant, ) { /** Musculation ou cardio, selon le type déclaré par l'application d'origine. */ val isStrength: Boolean get() = exerciseType == ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING || exerciseType == ExerciseSessionRecord.EXERCISE_TYPE_WEIGHTLIFTING + + /** Deux séances qui se recouvrent dans le temps décrivent le même entraînement. */ + fun overlaps(other: ForeignSession): Boolean = start < other.end && other.start < end + + /** Entre deux doublons : l'application préférée, puis la séance la plus longue. */ + fun preferredOver(other: ForeignSession): Boolean { + val mine = HealthConnectManager.rankOf(source) + val theirs = HealthConnectManager.rankOf(other.source) + return if (mine != theirs) mine < theirs else minutes > other.minutes + } } /** Résultat d'une synchronisation, pour l'afficher sans interpréter une exception. */ @@ -169,8 +180,10 @@ class HealthConnectManager(private val context: Context) { minutes = Duration.between(record.startTime, record.endTime) .toMinutes().toInt().coerceAtLeast(1), day = record.startTime.atZone(zone).toLocalDate(), + end = record.endTime, ) } + .let(::dropDuplicates) } catch (_: Exception) { emptyList() } @@ -384,6 +397,42 @@ class HealthConnectManager(private val context: Context) { } } + /** + * Applications préférées quand une même séance arrive deux fois, la première + * l'emportant. + * + * Le cas vient des passerelles : une séance faite dans Lyfta part sur Strava, + * qui l'écrit à son tour dans Health Connect. Les deux décrivent le même + * entraînement ; celle de l'application où il a été saisi est la plus fidèle. + */ + val PREFERRED_SOURCES = listOf("lyfta", "strava") + + /** + * Écarte les séances qui en recouvrent une autre. + * + * Deux enregistrements qui se chevauchent dans le temps décrivent le même + * entraînement : on ne garde que celui de l'application la mieux placée, et à + * défaut le plus long, qui porte en général le plus de détail. + */ + fun dropDuplicates(sessions: List): List { + val kept = mutableListOf() + for (session in sessions.sortedBy { it.start }) { + val rival = kept.indexOfFirst { it.overlaps(session) } + if (rival < 0) { + kept += session + } else if (session.preferredOver(kept[rival])) { + kept[rival] = session + } + } + return kept + } + + fun rankOf(source: String): Int { + val index = PREFERRED_SOURCES.indexOfFirst { source.contains(it, ignoreCase = true) } + // Une application inconnue passe après celles qu'on sait préférer + return if (index < 0) PREFERRED_SOURCES.size else index + } + /** Décalage horaire d'un instant local, exposé pour les tests. */ fun offsetFor(dateTime: LocalDateTime, zone: ZoneId): ZoneOffset = zone.rules.getOffset(dateTime) diff --git a/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt b/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt index a33bcab..0568d3b 100644 --- a/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt +++ b/app/src/main/java/com/kaz229/fittrack/ui/screens/HealthConnectScreen.kt @@ -146,7 +146,9 @@ fun HealthConnectScreen( Text( text = "Les séances enregistrées par une autre application (Lyfta, une montre…) " + "sont reprises dans ton historique, sans doublon : chacune garde son " + - "identifiant d'origine.", + "identifiant d'origine. Quand la même séance arrive deux fois — Lyfta la " + + "relaie sur Strava, qui l'écrit à son tour — seule celle de l'application " + + "où elle a été saisie est gardée.", style = MaterialTheme.typography.bodySmall, ) Text( diff --git a/app/src/test/java/com/kaz229/fittrack/ForeignSessionTest.kt b/app/src/test/java/com/kaz229/fittrack/ForeignSessionTest.kt new file mode 100644 index 0000000..24a5b81 --- /dev/null +++ b/app/src/test/java/com/kaz229/fittrack/ForeignSessionTest.kt @@ -0,0 +1,117 @@ +package com.kaz229.fittrack + +import androidx.health.connect.client.records.ExerciseSessionRecord +import com.kaz229.fittrack.data.ForeignSession +import com.kaz229.fittrack.data.HealthConnectManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Dédoublonnage des séances venues d'autres applications. + * + * Le cas réel : une séance faite dans Lyfta part sur Strava, qui l'écrit à son tour + * dans Health Connect. L'historique affichait la même séance deux fois. + */ +class ForeignSessionTest { + + private val zone = ZoneId.of("Europe/Paris") + private val base: Instant = LocalDate.of(2026, 11, 2).atTime(18, 0).atZone(zone).toInstant() + + private fun session( + id: String, + source: String, + startMinute: Long = 0, + minutes: Int = 60, + type: Int = ExerciseSessionRecord.EXERCISE_TYPE_STRENGTH_TRAINING, + ): ForeignSession { + val start = base.plusSeconds(startMinute * 60) + return ForeignSession( + id = id, + title = null, + exerciseType = type, + source = source, + start = start, + minutes = minutes, + day = LocalDate.of(2026, 11, 2), + end = start.plusSeconds(minutes * 60L), + ) + } + + @Test + fun la_seance_relayee_par_strava_cede_la_place_a_lyfta() { + val doublons = listOf( + session("strava-1", "com.strava"), + session("lyfta-1", "com.lyfta"), + ) + + val gardees = HealthConnectManager.dropDuplicates(doublons) + + assertEquals(listOf("lyfta-1"), gardees.map { it.id }) + } + + @Test + fun l_ordre_d_arrivee_ne_change_rien() { + val lyftaDabord = HealthConnectManager.dropDuplicates( + listOf(session("lyfta-1", "com.lyfta"), session("strava-1", "com.strava")), + ) + + assertEquals(listOf("lyfta-1"), lyftaDabord.map { it.id }) + } + + @Test + fun un_leger_decalage_entre_les_deux_reste_un_doublon() { + // Strava recale souvent le créneau de quelques minutes + val doublons = listOf( + session("lyfta-1", "com.lyfta", startMinute = 0, minutes = 60), + session("strava-1", "com.strava", startMinute = 3, minutes = 58), + ) + + assertEquals(listOf("lyfta-1"), HealthConnectManager.dropDuplicates(doublons).map { it.id }) + } + + @Test + fun deux_seances_distinctes_sont_toutes_les_deux_gardees() { + val journee = listOf( + session("lyfta-1", "com.lyfta", startMinute = 0, minutes = 60), + session("strava-course", "com.strava", startMinute = 120, minutes = 40), + ) + + // La course Strava n'a pas d'équivalent Lyfta : elle doit rester + assertEquals(2, HealthConnectManager.dropDuplicates(journee).size) + } + + @Test + fun entre_deux_applications_inconnues_la_plus_longue_gagne() { + val doublons = listOf( + session("montre-1", "com.marque.montre", minutes = 45), + session("autre-1", "com.autre.app", minutes = 62), + ) + + assertEquals(listOf("autre-1"), HealthConnectManager.dropDuplicates(doublons).map { it.id }) + } + + @Test + fun une_application_connue_passe_devant_une_inconnue_plus_longue() { + val doublons = listOf( + session("montre-1", "com.marque.montre", minutes = 90), + session("lyfta-1", "com.lyfta", minutes = 60), + ) + + assertEquals(listOf("lyfta-1"), HealthConnectManager.dropDuplicates(doublons).map { it.id }) + } + + @Test + fun les_preferences_sont_ordonnees() { + assertTrue(HealthConnectManager.rankOf("com.lyfta") < HealthConnectManager.rankOf("com.strava")) + assertTrue(HealthConnectManager.rankOf("com.strava") < HealthConnectManager.rankOf("com.inconnu")) + } + + @Test + fun une_liste_vide_ne_pose_pas_de_probleme() { + assertEquals(emptyList(), HealthConnectManager.dropDuplicates(emptyList())) + } +}