diff --git a/Packages/StrandImport/Sources/StrandImport/GpxHrInjector.swift b/Packages/StrandImport/Sources/StrandImport/GpxHrInjector.swift new file mode 100644 index 0000000000..97098b044b --- /dev/null +++ b/Packages/StrandImport/Sources/StrandImport/GpxHrInjector.swift @@ -0,0 +1,321 @@ +import Foundation + +// MARK: - GPX heart-rate injector +// +// Takes a GPX file exported from another service (Strava, Garmin Connect, …) that carries a GPS +// track but NO heart rate, and returns the SAME document with the strap's stored per-second HR +// injected into each timed trackpoint as the Garmin TrackPointExtension (`gpxtpx:hr`) — the form +// every major service reads back, Strava included. The result keeps position, elevation, times and +// everything else in the source file byte-for-byte: this is deliberate TEXT SURGERY on the original +// document, not a parse-and-reserialize, so nothing the exporting service wrote is lost or +// reordered. The only changes are the inserted extension blocks and, when the root `` tag +// lacks it, the `xmlns:gpxtpx` namespace declaration. +// +// Direction of use (issue #2679, option 1 family): the wearer records a run in another app, NOOP +// has the measured HR for that window in its own store, and the merged file is handed back to the +// wearer through the share sheet. Fully offline, file-in/file-out, nothing persisted; the local +// database stays the sole authority and nothing here touches the import or scoring paths. +// +// PARITY: `GpxHrInject.kt` (android `com.noop.ingest`) is the byte-identical Kotlin twin — same +// insertion text, same matching rule, same error cases. Verified by oracle (Swift stdout pasted as +// Kotlin expected literals), per AGENTS.md; change both together. +public enum GpxHrInjector { + + /// The namespace the inserted elements live in — Garmin's TrackPointExtension v1, which is what + /// Strava itself writes when a run HAS heart rate, so readers treat the merged file as native. + static let gpxtpxNamespace = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1" + + /// Widest plausible gap between a trackpoint's time and the nearest stored HR sample. The strap + /// stores per-second HR and GPX trackpoints are at most a few seconds apart, so 5 s matches + /// everything real while refusing to smear HR across a recording gap. + public static let defaultToleranceSec = 5 + + /// A successful merge: the rewritten document plus honest coverage counts for the UI line + /// ("Heart rate added to N of M points"). + public struct Injected: Sendable, Equatable { + /// The merged GPX document (UTF-8). + public let gpx: Data + /// Timed trackpoints that received an HR value. + public let matched: Int + /// Trackpoints that carried a parseable ` + + """ + return Data(s.utf8) + } + + private func iso(_ ts: Int) -> String { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f.string(from: Date(timeIntervalSince1970: TimeInterval(ts))) + } + + private func hr(_ pairs: [(Int, Int)]) -> [ActivityHRSample] { + pairs.map { ActivityHRSample(ts: $0.0, bpm: $0.1) } + } + + // MARK: - Happy path + + func testInjectsHrIntoEveryTimedTrackpointAndRoundTrips() throws { + let src = stravaGpx(points: 5) + let samples = hr((0..<5).map { (t0 + $0, 140 + $0) }) + let out = try GpxHrInjector.inject(gpx: src, hr: samples) + XCTAssertEqual(out.matched, 5) + XCTAssertEqual(out.total, 5) + XCTAssertEqual(out.startTs, t0) + XCTAssertEqual(out.endTs, t0 + 4) + + let text = String(data: out.gpx, encoding: .utf8)! + XCTAssertTrue(text.contains("xmlns:gpxtpx=\"http://www.garmin.com/xmlschemas/TrackPointExtension/v1\"")) + XCTAssertEqual(text.components(separatedBy: "").count - 1, 5) + XCTAssertTrue(text.contains("140")) + XCTAssertTrue(text.contains("144")) + + // Round-trip through the app's own GPX decoder: track, times, and the injected HR all read back. + let a = ActivityFileImporter.parse(data: out.gpx, filename: "run.gpx").activity + XCTAssertNotNil(a) + XCTAssertEqual(a!.route.count, 5) + XCTAssertEqual(a!.hrSampleCount, 5) + XCTAssertEqual(a!.hrSamples.map(\.bpm), [140, 141, 142, 143, 144]) + XCTAssertEqual(a!.avgHr, 142) + XCTAssertEqual(Int(a!.start.timeIntervalSince1970), t0) + } + + func testPreservesTheSourceDocumentOutsideTheInsertedBlocks() throws { + let src = stravaGpx(points: 3) + let samples = hr((0..<3).map { (t0 + $0, 150) }) + let out = try GpxHrInjector.inject(gpx: src, hr: samples) + var text = String(data: out.gpx, encoding: .utf8)! + // Strip exactly what the injector claims to add; the source must come back byte-for-byte. + text = text.replacingOccurrences( + of: "150", + with: "") + text = text.replacingOccurrences( + of: " xmlns:gpxtpx=\"http://www.garmin.com/xmlschemas/TrackPointExtension/v1\"", + with: "") + XCTAssertEqual(Data(text.utf8), src) + } + + func testUsesAnExistingExtensionsBlockInsteadOfAddingASecond() throws { + let src = """ + + + + 200 + + + """ + let out = try GpxHrInjector.inject(gpx: Data(src.utf8), hr: hr([(t0, 155)])) + let text = String(data: out.gpx, encoding: .utf8)! + XCTAssertEqual(text.components(separatedBy: "").count - 1, 1) + XCTAssertTrue(text.contains("200155")) + } + + func testDoesNotRedeclareAnExistingGpxtpxNamespace() throws { + let src = """ + + + + """ + let out = try GpxHrInjector.inject(gpx: Data(src.utf8), hr: hr([(t0, 120)])) + let text = String(data: out.gpx, encoding: .utf8)! + XCTAssertEqual(text.components(separatedBy: "xmlns:gpxtpx").count - 1, 1) + } + + // MARK: - Matching rules + + func testMatchesNearestSampleWithinToleranceAndSkipsBeyondIt() throws { + let src = stravaGpx(points: 3, step: 10) // t0, t0+10, t0+20 + // t0 exact; t0+10 nearest is 4 s away (within 5); t0+20 nearest is 6 s away (outside). + let samples = hr([(t0, 130), (t0 + 14, 161), (t0 + 26, 170)]) + let out = try GpxHrInjector.inject(gpx: src, hr: samples) + XCTAssertEqual(out.matched, 2) + XCTAssertEqual(out.total, 3) + let text = String(data: out.gpx, encoding: .utf8)! + XCTAssertTrue(text.contains("130")) + XCTAssertTrue(text.contains("161")) + XCTAssertFalse(text.contains("170")) + } + + func testEquidistantNeighboursTakeTheEarlierSample() throws { + let src = stravaGpx(points: 1) + let samples = hr([(t0 - 2, 111), (t0 + 2, 222)]) + let out = try GpxHrInjector.inject(gpx: src, hr: samples) + XCTAssertTrue(String(data: out.gpx, encoding: .utf8)!.contains("111")) + } + + func testUnsortedSamplesAreHandled() throws { + let src = stravaGpx(points: 2) + let samples = hr([(t0 + 1, 145), (t0, 140)]) + let out = try GpxHrInjector.inject(gpx: src, hr: samples) + XCTAssertEqual(out.matched, 2) + } + + // MARK: - Refusals (each must be LOUD and carry its numbers) + + func testRefusesAFileThatAlreadyCarriesHeartRate() { + let src = """ + + + 150 + + + + """ + XCTAssertThrowsError(try GpxHrInjector.inject(gpx: Data(src.utf8), hr: hr([(t0, 1)]))) { error in + XCTAssertEqual(error as? GpxHrInjector.Failure, .alreadyHasHeartRate(points: 1)) + } + } + + func testRefusesWhenNoSampleFallsInTheWindow() { + let src = stravaGpx(points: 3) + let samples = hr([(t0 + 3600, 140)]) + XCTAssertThrowsError(try GpxHrInjector.inject(gpx: src, hr: samples)) { error in + XCTAssertEqual(error as? GpxHrInjector.Failure, + .noHeartRateMatched(startTs: t0, endTs: t0 + 2, points: 3, samples: 1)) + } + } + + func testRefusesWhenNoSamplesAreOfferedAtAll() { + XCTAssertThrowsError(try GpxHrInjector.inject(gpx: stravaGpx(points: 2), hr: [])) { error in + XCTAssertEqual(error as? GpxHrInjector.Failure, + .noHeartRateMatched(startTs: t0, endTs: t0 + 1, points: 2, samples: 0)) + } + } + + func testRefusesATrackWithNoTimestamps() { + let src = """ + + 10 + + """ + XCTAssertThrowsError(try GpxHrInjector.inject(gpx: Data(src.utf8), hr: hr([(t0, 1)]))) { error in + XCTAssertEqual(error as? GpxHrInjector.Failure, .noTimedTrackpoints) + } + XCTAssertThrowsError(try GpxHrInjector.timeWindow(gpx: Data(src.utf8))) + } + + func testRefusesNonGpxData() { + XCTAssertThrowsError(try GpxHrInjector.inject(gpx: Data("not xml".utf8), hr: hr([(t0, 1)]))) { error in + XCTAssertEqual(error as? GpxHrInjector.Failure, .notGpx) + } + } + + // MARK: - Window + + func testTimeWindowReadsFirstToLastTimedPoint() throws { + let window = try GpxHrInjector.timeWindow(gpx: stravaGpx(points: 4, step: 30)) + XCTAssertEqual(window, t0...(t0 + 90)) + } + + func testSelfClosingTrackpointsAreSkippedNotFatal() throws { + let src = """ + + + + + + + """ + let out = try GpxHrInjector.inject(gpx: Data(src.utf8), hr: hr([(t0, 133)])) + XCTAssertEqual(out.matched, 1) + XCTAssertEqual(out.total, 1) + } +} diff --git a/Strand/Resources/Localizable.xcstrings b/Strand/Resources/Localizable.xcstrings index 7f227851a7..95126ccb9a 100644 --- a/Strand/Resources/Localizable.xcstrings +++ b/Strand/Resources/Localizable.xcstrings @@ -219451,7 +219451,19 @@ "Date of birth": {"localizations": {"de": {"stringUnit": {"state": "translated", "value": "Geburtsdatum"}}, "es": {"stringUnit": {"state": "translated", "value": "Fecha de nacimiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Date de naissance"}}, "it": {"stringUnit": {"state": "translated", "value": "Data di nascita"}}, "pl": {"stringUnit": {"state": "translated", "value": "Data urodzenia"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Data de nascimento"}}, "ru": {"stringUnit": {"state": "translated", "value": "Дата рождения"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "出生日期"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "出生日期"}}}}, "Day cycle": {"localizations": {"de": {"stringUnit": {"state": "translated", "value": "Tageszyklus"}}, "es": {"stringUnit": {"state": "translated", "value": "Ciclo diario"}}, "fr": {"stringUnit": {"state": "translated", "value": "Cycle journalier"}}, "it": {"stringUnit": {"state": "translated", "value": "Ciclo giornaliero"}}, "pl": {"stringUnit": {"state": "translated", "value": "Cykl dobowy"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Ciclo diário"}}, "ru": {"stringUnit": {"state": "translated", "value": "Суточный цикл"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "每日周期"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "每日週期"}}}}, "Raw on-device stages": {"localizations": {"de": {"stringUnit": {"state": "translated", "value": "Rohe Phasen vom Gerät"}}, "es": {"stringUnit": {"state": "translated", "value": "Fases sin procesar del dispositivo"}}, "fr": {"stringUnit": {"state": "translated", "value": "Stades bruts de l'appareil"}}, "it": {"stringUnit": {"state": "translated", "value": "Fasi grezze dal dispositivo"}}, "pl": {"stringUnit": {"state": "translated", "value": "Surowe fazy z urządzenia"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Estágios brutos do dispositivo"}}, "ru": {"stringUnit": {"state": "translated", "value": "Исходные стадии с устройства"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "设备端原始睡眠阶段"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "裝置端原始睡眠階段"}}}}, - "Oura": {"shouldTranslate": false} + "Oura": {"shouldTranslate": false}, + "Add heart rate to a workout file": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Add heart rate to a workout file"}}, "de": {"stringUnit": {"state": "translated", "value": "Herzfrequenz zu einer Workout-Datei hinzufügen"}}, "es": {"stringUnit": {"state": "translated", "value": "Añadir frecuencia cardíaca a un archivo de entrenamiento"}}, "fr": {"stringUnit": {"state": "translated", "value": "Ajouter la fréquence cardiaque à un fichier d'entraînement"}}, "it": {"stringUnit": {"state": "translated", "value": "Aggiungi la frequenza cardiaca a un file di allenamento"}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodaj tętno do pliku ćwiczeń"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Adicionar frequência cardíaca a um ficheiro de treino"}}, "ru": {"stringUnit": {"state": "translated", "value": "Добавить пульс в файл тренировки"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "将心率添加到锻炼文件"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "將心率加入鍛鍊檔案"}}}}, + "Recorded a run or ride in another app without a heart-rate sensor? Export its GPX (Strava: activity → ⋯ → Export GPX), choose it here, and NOOP merges the heart rate your strap recorded over that window into the file — route and times untouched. Share the merged file back to the app to see heart rate and zones there. Fully offline; nothing leaves %@.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Recorded a run or ride in another app without a heart-rate sensor? Export its GPX (Strava: activity → ⋯ → Export GPX), choose it here, and NOOP merges the heart rate your strap recorded over that window into the file — route and times untouched. Share the merged file back to the app to see heart rate and zones there. Fully offline; nothing leaves %@."}}, "de": {"stringUnit": {"state": "translated", "value": "Lauf oder Fahrt in einer anderen App ohne Herzfrequenzsensor aufgezeichnet? Exportiere das GPX (Strava: Aktivität → ⋯ → GPX exportieren), wähle es hier aus, und NOOP fügt die Herzfrequenz, die dein Band in diesem Zeitfenster aufgezeichnet hat, in die Datei ein — Route und Zeiten bleiben unangetastet. Teile die zusammengeführte Datei zurück in die App, um dort Herzfrequenz und Zonen zu sehen. Komplett offline; nichts verlässt %@."}}, "es": {"stringUnit": {"state": "translated", "value": "¿Grabaste una carrera o salida en otra app sin sensor de frecuencia cardíaca? Exporta su GPX (Strava: actividad → ⋯ → Exportar GPX), elígelo aquí y NOOP fusiona en el archivo la frecuencia cardíaca que tu correa registró en esa ventana — ruta y tiempos intactos. Comparte el archivo fusionado de vuelta a la app para ver allí la frecuencia cardíaca y las zonas. Totalmente sin conexión; nada sale de %@."}}, "fr": {"stringUnit": {"state": "translated", "value": "Course ou sortie enregistrée dans une autre app sans capteur de fréquence cardiaque ? Exportez son GPX (Strava : activité → ⋯ → Exporter le GPX), choisissez-le ici, et NOOP fusionne dans le fichier la fréquence cardiaque enregistrée par votre bracelet sur cette fenêtre — itinéraire et temps intacts. Partagez le fichier fusionné vers l'app pour y voir la fréquence cardiaque et les zones. Entièrement hors ligne ; rien ne quitte %@."}}, "it": {"stringUnit": {"state": "translated", "value": "Hai registrato una corsa o un'uscita in un'altra app senza sensore di frequenza cardiaca? Esporta il suo GPX (Strava: attività → ⋯ → Esporta GPX), sceglilo qui e NOOP unisce nel file la frequenza cardiaca registrata dalla tua fascia in quella finestra — percorso e tempi intatti. Condividi il file unito di nuovo nell'app per vedere lì frequenza cardiaca e zone. Completamente offline; niente lascia %@."}}, "pl": {"stringUnit": {"state": "translated", "value": "Nagrałeś bieg lub przejażdżkę w innej aplikacji bez czujnika tętna? Wyeksportuj jej GPX (Strava: aktywność → ⋯ → Eksportuj GPX), wybierz go tutaj, a NOOP scali w pliku tętno zarejestrowane przez twoją opaskę w tym oknie — trasa i czasy pozostają nietknięte. Udostępnij scalony plik z powrotem do aplikacji, aby zobaczyć tam tętno i strefy. Całkowicie offline; nic nie opuszcza %@."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Gravaste uma corrida ou volta noutra app sem sensor de frequência cardíaca? Exporta o seu GPX (Strava: atividade → ⋯ → Exportar GPX), escolhe-o aqui e o NOOP funde no ficheiro a frequência cardíaca que a tua banda registou nessa janela — rota e tempos intactos. Partilha o ficheiro fundido de volta para a app para veres lá a frequência cardíaca e as zonas. Totalmente offline; nada sai de %@."}}, "ru": {"stringUnit": {"state": "translated", "value": "Записали пробежку или заезд в другом приложении без датчика пульса? Экспортируйте его GPX (Strava: активность → ⋯ → Экспорт GPX), выберите его здесь, и NOOP добавит в файл пульс, записанный вашим браслетом за это окно — маршрут и время не затрагиваются. Поделитесь объединённым файлом обратно в приложение, чтобы увидеть там пульс и зоны. Полностью офлайн: ничего не покидает %@."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "在其他应用中记录了跑步或骑行但没有心率传感器?导出其 GPX(Strava:活动 → ⋯ → 导出 GPX),在此选择该文件,NOOP 会把手环在该时间段记录的心率合并进文件——路线和时间保持不变。将合并后的文件分享回该应用,即可在那里查看心率和心率区间。完全离线;任何数据都不会离开 %@。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "在其他應用程式記錄了跑步或騎行但沒有心率感測器?匯出其 GPX(Strava:活動 → ⋯ → 匯出 GPX),在此選擇該檔案,NOOP 會把手環在該時段記錄的心率合併進檔案——路線與時間保持不變。將合併後的檔案分享回該應用程式,即可在那裡查看心率與心率區間。完全離線;沒有任何內容離開 %@。"}}}}, + "Merging…": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Merging…"}}, "de": {"stringUnit": {"state": "translated", "value": "Führe zusammen…"}}, "es": {"stringUnit": {"state": "translated", "value": "Fusionando…"}}, "fr": {"stringUnit": {"state": "translated", "value": "Fusion…"}}, "it": {"stringUnit": {"state": "translated", "value": "Unione…"}}, "pl": {"stringUnit": {"state": "translated", "value": "Scalanie…"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "A fundir…"}}, "ru": {"stringUnit": {"state": "translated", "value": "Объединение…"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "正在合并…"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "正在合併…"}}}}, + "Choose .gpx…": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Choose .gpx…"}}, "de": {"stringUnit": {"state": "translated", "value": "Wähle .gpx…"}}, "es": {"stringUnit": {"state": "translated", "value": "Elegir .gpx…"}}, "fr": {"stringUnit": {"state": "translated", "value": "Choisir un .gpx…"}}, "it": {"stringUnit": {"state": "translated", "value": "Scegli .gpx…"}}, "pl": {"stringUnit": {"state": "translated", "value": "Wybierz .gpx…"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Escolher .gpx…"}}, "ru": {"stringUnit": {"state": "translated", "value": "Выбрать .gpx…"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "选择 .gpx…"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "選擇 .gpx…"}}}}, + "That file is too large to merge.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "That file is too large to merge."}}, "de": {"stringUnit": {"state": "translated", "value": "Diese Datei ist zu groß zum Zusammenführen."}}, "es": {"stringUnit": {"state": "translated", "value": "Ese archivo es demasiado grande para fusionarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier est trop volumineux pour être fusionné."}}, "it": {"stringUnit": {"state": "translated", "value": "Quel file è troppo grande per essere unito."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten plik jest zbyt duży, aby go scalić."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esse ficheiro é demasiado grande para fundir."}}, "ru": {"stringUnit": {"state": "translated", "value": "Файл слишком велик для объединения."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件过大,无法合并。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案過大,無法合併。"}}}}, + "Heart rate added to %lld of %lld track points. Upload the merged file back to the app the activity came from.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Heart rate added to %lld of %lld track points. Upload the merged file back to the app the activity came from."}}, "de": {"stringUnit": {"state": "translated", "value": "Herzfrequenz zu %lld von %lld Trackpunkten hinzugefügt. Lade die zusammengeführte Datei wieder in die App hoch, aus der die Aktivität stammt."}}, "es": {"stringUnit": {"state": "translated", "value": "Frecuencia cardíaca añadida a %lld de %lld puntos de la ruta. Sube el archivo fusionado de vuelta a la app de la que vino la actividad."}}, "fr": {"stringUnit": {"state": "translated", "value": "Fréquence cardiaque ajoutée à %lld points sur %lld. Téléversez le fichier fusionné vers l'app d'où vient l'activité."}}, "it": {"stringUnit": {"state": "translated", "value": "Frequenza cardiaca aggiunta a %lld punti su %lld. Carica il file unito di nuovo nell'app da cui proviene l'attività."}}, "pl": {"stringUnit": {"state": "translated", "value": "Dodano tętno do %lld z %lld punktów trasy. Prześlij scalony plik z powrotem do aplikacji, z której pochodzi aktywność."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Frequência cardíaca adicionada a %lld de %lld pontos do trajeto. Envia o ficheiro fundido de volta para a app de onde veio a atividade."}}, "ru": {"stringUnit": {"state": "translated", "value": "Пульс добавлен к %lld из %lld точек трека. Загрузите объединённый файл обратно в приложение, откуда взята активность."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "已将心率添加到 %lld/%lld 个轨迹点。请将合并后的文件上传回该活动所属的应用。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "已將心率加入 %lld/%lld 個軌跡點。請將合併後的檔案上傳回該活動所屬的應用程式。"}}}}, + "Couldn't read that file: %@": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "Couldn't read that file: %@"}}, "de": {"stringUnit": {"state": "translated", "value": "Datei konnte nicht gelesen werden: %@"}}, "es": {"stringUnit": {"state": "translated", "value": "No se pudo leer ese archivo: %@"}}, "fr": {"stringUnit": {"state": "translated", "value": "Impossible de lire ce fichier : %@"}}, "it": {"stringUnit": {"state": "translated", "value": "Impossibile leggere quel file: %@"}}, "pl": {"stringUnit": {"state": "translated", "value": "Nie można odczytać tego pliku: %@"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Não foi possível ler esse ficheiro: %@"}}, "ru": {"stringUnit": {"state": "translated", "value": "Не удалось прочитать файл: %@"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "无法读取该文件:%@"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "無法讀取該檔案:%@"}}}}, + "That file isn't a GPX document. Export the activity as GPX (Strava: activity → ⋯ → Export GPX) and choose that file.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "That file isn't a GPX document. Export the activity as GPX (Strava: activity → ⋯ → Export GPX) and choose that file."}}, "de": {"stringUnit": {"state": "translated", "value": "Diese Datei ist kein GPX-Dokument. Exportiere die Aktivität als GPX (Strava: Aktivität → ⋯ → GPX exportieren) und wähle diese Datei."}}, "es": {"stringUnit": {"state": "translated", "value": "Ese archivo no es un documento GPX. Exporta la actividad como GPX (Strava: actividad → ⋯ → Exportar GPX) y elige ese archivo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier n'est pas un document GPX. Exportez l'activité en GPX (Strava : activité → ⋯ → Exporter le GPX) et choisissez ce fichier."}}, "it": {"stringUnit": {"state": "translated", "value": "Quel file non è un documento GPX. Esporta l'attività come GPX (Strava: attività → ⋯ → Esporta GPX) e scegli quel file."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten plik nie jest dokumentem GPX. Wyeksportuj aktywność jako GPX (Strava: aktywność → ⋯ → Eksportuj GPX) i wybierz ten plik."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Esse ficheiro não é um documento GPX. Exporta a atividade como GPX (Strava: atividade → ⋯ → Exportar GPX) e escolhe esse ficheiro."}}, "ru": {"stringUnit": {"state": "translated", "value": "Это не GPX-документ. Экспортируйте активность как GPX (Strava: активность → ⋯ → Экспорт GPX) и выберите этот файл."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件不是 GPX 文档。请将活动导出为 GPX(Strava:活动 → ⋯ → 导出 GPX)并选择该文件。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案不是 GPX 文件。請將活動匯出為 GPX(Strava:活動 → ⋯ → 匯出 GPX)並選擇該檔案。"}}}}, + "The file's track carries no timestamps, so heart rate can't be matched to it.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "The file's track carries no timestamps, so heart rate can't be matched to it."}}, "de": {"stringUnit": {"state": "translated", "value": "Der Track der Datei enthält keine Zeitstempel, daher kann die Herzfrequenz nicht zugeordnet werden."}}, "es": {"stringUnit": {"state": "translated", "value": "La ruta del archivo no tiene marcas de tiempo, así que la frecuencia cardíaca no se puede emparejar."}}, "fr": {"stringUnit": {"state": "translated", "value": "La trace du fichier ne contient aucun horodatage, la fréquence cardiaque ne peut donc pas y être associée."}}, "it": {"stringUnit": {"state": "translated", "value": "La traccia del file non contiene timestamp, quindi la frequenza cardiaca non può esservi abbinata."}}, "pl": {"stringUnit": {"state": "translated", "value": "Trasa w pliku nie zawiera znaczników czasu, więc nie można dopasować tętna."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O trajeto do ficheiro não tem marcas de tempo, por isso a frequência cardíaca não pode ser associada."}}, "ru": {"stringUnit": {"state": "translated", "value": "Трек в файле не содержит отметок времени, поэтому пульс сопоставить невозможно."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件的轨迹没有时间戳,因此无法匹配心率。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案的軌跡沒有時間戳記,因此無法匹配心率。"}}}}, + "This file already carries heart rate (%lld track points) — there's nothing to add.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "This file already carries heart rate (%lld track points) — there's nothing to add."}}, "de": {"stringUnit": {"state": "translated", "value": "Diese Datei enthält bereits Herzfrequenz (%lld Trackpunkte) — es gibt nichts hinzuzufügen."}}, "es": {"stringUnit": {"state": "translated", "value": "Este archivo ya contiene frecuencia cardíaca (%lld puntos de la ruta) — no hay nada que añadir."}}, "fr": {"stringUnit": {"state": "translated", "value": "Ce fichier contient déjà la fréquence cardiaque (%lld points) — il n'y a rien à ajouter."}}, "it": {"stringUnit": {"state": "translated", "value": "Questo file contiene già la frequenza cardiaca (%lld punti) — non c'è nulla da aggiungere."}}, "pl": {"stringUnit": {"state": "translated", "value": "Ten plik już zawiera tętno (%lld punktów trasy) — nie ma nic do dodania."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Este ficheiro já contém frequência cardíaca (%lld pontos do trajeto) — não há nada a adicionar."}}, "ru": {"stringUnit": {"state": "translated", "value": "В этом файле уже есть пульс (%lld точек трека) — добавлять нечего."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件已包含心率(%lld 个轨迹点)——无需添加。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案已包含心率(%lld 個軌跡點)——無需加入。"}}}}, + "No recorded heart rate overlaps this activity (%@; %lld track points, %lld stored samples nearby). Was the strap worn and synced for that window?": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "No recorded heart rate overlaps this activity (%@; %lld track points, %lld stored samples nearby). Was the strap worn and synced for that window?"}}, "de": {"stringUnit": {"state": "translated", "value": "Keine aufgezeichnete Herzfrequenz überschneidet sich mit dieser Aktivität (%@; %lld Trackpunkte, %lld gespeicherte Messwerte in der Nähe). Wurde das Band in diesem Zeitfenster getragen und synchronisiert?"}}, "es": {"stringUnit": {"state": "translated", "value": "Ninguna frecuencia cardíaca registrada coincide con esta actividad (%@; %lld puntos de la ruta, %lld muestras guardadas cercanas). ¿Llevabas la correa puesta y sincronizada en esa ventana?"}}, "fr": {"stringUnit": {"state": "translated", "value": "Aucune fréquence cardiaque enregistrée ne recouvre cette activité (%@ ; %lld points, %lld échantillons stockés à proximité). Le bracelet était-il porté et synchronisé sur cette fenêtre ?"}}, "it": {"stringUnit": {"state": "translated", "value": "Nessuna frequenza cardiaca registrata copre questa attività (%@; %lld punti, %lld campioni memorizzati nelle vicinanze). La fascia era indossata e sincronizzata in quella finestra?"}}, "pl": {"stringUnit": {"state": "translated", "value": "Żadne zarejestrowane tętno nie pokrywa się z tą aktywnością (%@; %lld punktów trasy, %lld zapisanych próbek w pobliżu). Czy opaska była noszona i zsynchronizowana w tym oknie?"}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "Nenhuma frequência cardíaca registada coincide com esta atividade (%@; %lld pontos do trajeto, %lld amostras guardadas próximas). A banda estava a ser usada e sincronizada nessa janela?"}}, "ru": {"stringUnit": {"state": "translated", "value": "Записанный пульс не пересекается с этой активностью (%@; %lld точек трека, %lld сохранённых измерений рядом). Был ли браслет надет и синхронизирован в этом окне?"}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "没有记录的心率与此活动重叠(%@;%lld 个轨迹点,附近有 %lld 条已存样本)。该时间段内是否佩戴并同步了手环?"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "沒有記錄的心率與此活動重疊(%@;%lld 個軌跡點,附近有 %lld 筆已存樣本)。該時段內是否有配戴並同步手環?"}}}}, + "The file has too many track points to merge.": {"localizations": {"en": {"stringUnit": {"state": "translated", "value": "The file has too many track points to merge."}}, "de": {"stringUnit": {"state": "translated", "value": "Die Datei hat zu viele Trackpunkte zum Zusammenführen."}}, "es": {"stringUnit": {"state": "translated", "value": "El archivo tiene demasiados puntos de ruta para fusionarlo."}}, "fr": {"stringUnit": {"state": "translated", "value": "Le fichier contient trop de points pour être fusionné."}}, "it": {"stringUnit": {"state": "translated", "value": "Il file ha troppi punti traccia per essere unito."}}, "pl": {"stringUnit": {"state": "translated", "value": "Plik ma zbyt wiele punktów trasy, aby go scalić."}}, "pt-PT": {"stringUnit": {"state": "translated", "value": "O ficheiro tem demasiados pontos de trajeto para fundir."}}, "ru": {"stringUnit": {"state": "translated", "value": "В файле слишком много точек трека для объединения."}}, "zh-Hans": {"stringUnit": {"state": "translated", "value": "该文件的轨迹点过多,无法合并。"}}, "zh-Hant": {"stringUnit": {"state": "translated", "value": "該檔案的軌跡點過多,無法合併。"}}}} }, "version": "1.0" } diff --git a/Strand/Screens/DataSourcesView.swift b/Strand/Screens/DataSourcesView.swift index 08dc40d5f2..f020358f1c 100644 --- a/Strand/Screens/DataSourcesView.swift +++ b/Strand/Screens/DataSourcesView.swift @@ -34,6 +34,12 @@ struct DataSourcesView: View { @State private var wearableImporting = false @State private var wearableSummary: String? @State private var wearableFailed = false + // GPX heart-rate merge state (#2679, option-1 family): READ-ONLY on the store — the picked file's + // time window selects stored HR samples, GpxHrInjector splices them in, and the merged file goes + // straight back out through the share sheet / save panel. Nothing is imported or persisted. + @State private var hrMergeBusy = false + @State private var hrMergeSummary: String? + @State private var hrMergeFailed = false #if OURA_CLOUD_IMPORT // Oura history import (compiled in ONLY with OURA_CLOUD_IMPORT): a one-time, user-initiated, // foreground OAuth + backfill of the user's own history over the Oura API, as an alternative to @@ -98,11 +104,12 @@ struct DataSourcesView: View { liftingCard.staggeredAppear(index: 4) activityFileCard.staggeredAppear(index: 5) wearableCard.staggeredAppear(index: 6) + hrMergeCard.staggeredAppear(index: 7) #if OURA_CLOUD_IMPORT - ouraCloudCard.staggeredAppear(index: 7) + ouraCloudCard.staggeredAppear(index: 8) #endif - broadcastHrCard.staggeredAppear(index: 8) - liveCard.staggeredAppear(index: 9) + broadcastHrCard.staggeredAppear(index: 9) + liveCard.staggeredAppear(index: 10) } } .onAppear { @@ -296,6 +303,31 @@ struct DataSourcesView: View { } } + /// GPX heart-rate merge (#2679, option-1 family): the OUTBOUND sibling of the activity-file + /// importer above. The wearer exports a run recorded in another app (Strava: activity → ⋯ → + /// Export GPX), NOOP splices the strap's stored heart rate into its trackpoints by timestamp + /// (`GpxHrInjector`), and the merged file goes back out through the share sheet — upload it to + /// the same service and the activity shows the route AND heart rate/zones. Fully offline, + /// file-in/file-out; the store is only read, never written. + private var hrMergeCard: some View { + card(title: String(localized: "Add heart rate to a workout file"), icon: "heart.text.square", + tint: DomainTheme.effort.color, + subtitle: String(localized: "Recorded a run or ride in another app without a heart-rate sensor? Export its GPX (Strava: activity → ⋯ → Export GPX), choose it here, and NOOP merges the heart rate your strap recorded over that window into the file — route and times untouched. Share the merged file back to the app to see heart rate and zones there. Fully offline; nothing leaves \(Platform.deviceNounPhrase).")) { + HStack(spacing: NoopMetrics.space3) { + Button { presentImporter(.hrMerge) } label: { + Label(hrMergeBusy ? "Merging…" : "Choose .gpx…", systemImage: "heart.circle") + } + .buttonStyle(NoopButtonStyle(.primary)) + .disabled(hrMergeBusy) + if hrMergeBusy { ProgressView().controlSize(.small) } + } + if let s = hrMergeSummary { + Text(s).font(StrandFont.subhead) + .foregroundStyle(hrMergeFailed ? StrandPalette.statusWarning : StrandPalette.statusPositive) + } + } + } + #if OURA_CLOUD_IMPORT /// Oura history import: a one-time, user-initiated, foreground OAuth + API backfill of the user's /// own history — an *import* in the same family as the export-file importers above, not a sync @@ -374,6 +406,8 @@ struct DataSourcesView: View { importActivityFile(url: url) case .wearable: importWearable(url: url) + case .hrMerge: + mergeHrIntoFile(url: url) } } @@ -518,6 +552,91 @@ struct DataSourcesView: View { /// on a day a whole-day WHOOP import (priority 2) has HR, that import wins over the ride too. The /// workout row itself still stores `strain = nil` (we never fabricate a per-workout strain); the day /// Effort is computed from the measured HR stream, exactly as it is for a worn strap. + /// Merge the strap's stored HR into a picked GPX file and hand the result straight back out + /// (share sheet on iOS, save panel on macOS). READ-ONLY on the store: the file's own time window + /// selects `repo.hrSamples` — the same union/dedup funnel every HR read goes through — and the + /// merged bytes never touch the import pipeline. Every refusal is surfaced with its numbers; a + /// silent re-export of the unchanged file is the one outcome this flow must never produce. + private func mergeHrIntoFile(url: URL) { + hrMergeBusy = true + hrMergeSummary = nil + hrMergeFailed = false + Task { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + // Same read cap as the importer — a hostile huge file can't OOM us before the guards. + let data = try Data(contentsOf: url, options: [.mappedIfSafe]) + if data.count > ActivityFileImporter.maxBytes { + hrMergeSummary = String(localized: "That file is too large to merge.") + hrMergeFailed = true + logImport("HR merge failed: file too large") + hrMergeBusy = false + return + } + let window = try GpxHrInjector.timeWindow(gpx: data) + // Pad by the match tolerance so an edge trackpoint still finds its nearest sample. + let pad = GpxHrInjector.defaultToleranceSec + let samples = await repo.hrSamples(from: window.lowerBound - pad, + to: window.upperBound + pad, + limit: StreamReadCap.hr) + let merged = try GpxHrInjector.inject( + gpx: data, + hr: samples.map { ActivityHRSample(ts: $0.ts, bpm: $0.bpm) }) + // Named from the source file so the pair stays matched in a Files folder. + let name = "\(url.deletingPathExtension().lastPathComponent)-hr.gpx" + let out = NoopScratch.file(name) + try merged.gpx.write(to: out) + hrMergeSummary = String(localized: "Heart rate added to \(merged.matched) of \(merged.total) track points. Upload the merged file back to the app the activity came from.") + logImport("HR merge: \(merged.matched)/\(merged.total) points matched") + hrMergeBusy = false + FileExport.exportFile(at: out, suggestedName: name) + } catch let failure as GpxHrInjector.Failure { + hrMergeSummary = hrMergeMessage(for: failure) + hrMergeFailed = true + logImport("HR merge failed: \(hrMergeLogLabel(for: failure))") + hrMergeBusy = false + } catch { + // Read failure (permissions, vanished file) — surface it rather than a silent return. + hrMergeSummary = String(localized: "Couldn't read that file: \(error.localizedDescription)") + hrMergeFailed = true + logImport("HR merge failed: unreadable file") + hrMergeBusy = false + } + } + } + + /// User-facing explanation for each merge refusal — every case says what happened AND what to do. + private func hrMergeMessage(for failure: GpxHrInjector.Failure) -> String { + switch failure { + case .notGpx: + return String(localized: "That file isn't a GPX document. Export the activity as GPX (Strava: activity → ⋯ → Export GPX) and choose that file.") + case .noTimedTrackpoints: + return String(localized: "The file's track carries no timestamps, so heart rate can't be matched to it.") + case .alreadyHasHeartRate(let points): + return String(localized: "This file already carries heart rate (\(points) track points) — there's nothing to add.") + case .noHeartRateMatched(let startTs, let endTs, let points, let samples): + let fmt = DateFormatter() + fmt.dateStyle = .medium + fmt.timeStyle = .short + let span = "\(fmt.string(from: Date(timeIntervalSince1970: TimeInterval(startTs)))) – \(fmt.string(from: Date(timeIntervalSince1970: TimeInterval(endTs))))" + return String(localized: "No recorded heart rate overlaps this activity (\(span); \(points) track points, \(samples) stored samples nearby). Was the strap worn and synced for that window?") + case .tooManyTrackpoints: + return String(localized: "The file has too many track points to merge.") + } + } + + /// Strap-log label for a refusal: the CASE only, never a value that could identify the file. + private func hrMergeLogLabel(for failure: GpxHrInjector.Failure) -> String { + switch failure { + case .notGpx: return "not gpx" + case .noTimedTrackpoints: return "no timed trackpoints" + case .alreadyHasHeartRate: return "file already has HR" + case .noHeartRateMatched: return "no HR in window" + case .tooManyTrackpoints: return "too many trackpoints" + } + } + private func importActivityFile(url: URL) { activityFileImporting = true activityFileSummary = nil @@ -757,6 +876,7 @@ struct DataSourcesView: View { case lifting case activityFile case wearable + case hrMerge var allowedContentTypes: [UTType] { // `.folder` lets macOS users point at an *unzipped* export directory. On iOS the Files @@ -807,6 +927,11 @@ struct DataSourcesView: View { #else return [.json, .zip, .data] #endif + case .hrMerge: + // GPX only (the injector is GPX text surgery). .xml/.data keep a share-sheet file + // typed text/xml or untyped selectable; a non-GPX pick is refused loudly by the merge. + let gpx = UTType(filenameExtension: "gpx") ?? .xml + return [gpx, .xml, .data] } } } diff --git a/android/app/src/main/java/com/noop/ingest/GpxHrInject.kt b/android/app/src/main/java/com/noop/ingest/GpxHrInject.kt new file mode 100644 index 0000000000..f37400bc1c --- /dev/null +++ b/android/app/src/main/java/com/noop/ingest/GpxHrInject.kt @@ -0,0 +1,323 @@ +package com.noop.ingest + +/** + * GPX heart-rate injector — the byte-for-byte twin of the Swift `GpxHrInjector` in + * `Packages/StrandImport` (verified by oracle in [GpxHrInjectTest]: the Swift implementation's stdout + * over shared fixtures is pasted there as the expected literals; change both together). + * + * Takes a GPX file exported from another service (Strava, Garmin Connect, …) that carries a GPS + * track but NO heart rate, and returns the SAME document with the strap's stored per-second HR + * injected into each timed trackpoint as the Garmin TrackPointExtension (`gpxtpx:hr`) — the form + * every major service reads back, Strava included. Deliberate TEXT SURGERY on the original document, + * not a parse-and-reserialize: position, elevation, times and everything else survive byte-for-byte; + * the only changes are the inserted extension blocks and, when the root `` tag lacks it, the + * `xmlns:gpxtpx` namespace declaration. + * + * Direction of use (issue #2679, option-1 family): the wearer records a run in another app, NOOP has + * the measured HR for that window in its own store, and the merged file is handed back through the + * share intent. Fully offline, file-in/file-out, nothing persisted. Pure JVM — no Android types. + */ +object GpxHrInject { + + /** The namespace the inserted elements live in — what Strava itself writes when a run HAS HR. */ + const val GPXTPX_NAMESPACE = "http://www.garmin.com/xmlschemas/TrackPointExtension/v1" + + /** + * Widest plausible gap between a trackpoint's time and the nearest stored HR sample. The strap + * stores per-second HR and GPX trackpoints are at most a few seconds apart, so 5 s matches + * everything real while refusing to smear HR across a recording gap. + */ + const val DEFAULT_TOLERANCE_SEC = 5 + + /** A successful merge: the rewritten document plus honest coverage counts for the UI line. */ + data class Injected( + val gpx: String, + /** Timed trackpoints that received an HR value. */ + val matched: Int, + /** Trackpoints that carried a parseable `