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())) + } +}