Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/Daos.kt
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ interface SessionDao {
@Query("SELECT externalId FROM sessions WHERE externalId IS NOT NULL")
suspend fun importedExternalIds(): List<String>

/** 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<Session>

/** Réécrit les séances d'une restauration, identifiants d'origine compris. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(sessions: List<Session>)
Expand Down
14 changes: 13 additions & 1 deletion app/src/main/java/com/kaz229/fittrack/data/FitTrackRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions app/src/main/java/com/kaz229/fittrack/data/HealthConnect.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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<ForeignSession>): List<ForeignSession> {
val kept = mutableListOf<ForeignSession>()
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
117 changes: 117 additions & 0 deletions app/src/test/java/com/kaz229/fittrack/ForeignSessionTest.kt
Original file line number Diff line number Diff line change
@@ -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<ForeignSession>(), HealthConnectManager.dropDuplicates(emptyList()))
}
}
Loading