Skip to content

iOS sync (2026-08-08): port 8 PRs across Tiers 1–3 - #45

Merged
foureight84 merged 14 commits into
mainfrom
ios_sync_2026-08-08
Aug 9, 2026
Merged

iOS sync (2026-08-08): port 8 PRs across Tiers 1–3#45
foureight84 merged 14 commits into
mainfrom
ios_sync_2026-08-08

Conversation

@foureight84

Copy link
Copy Markdown
Owner

iOS → Android sync — 2026-08-08 triage

Ports 8 iOS PRs from PulseLoopiOS main since 2026-07-18 (commit 0d1b96588c0f6b). 3 additional items verified as already-have/skip.

Tier 1 — Quick wins

  • #73 Privacy & Data Reset — Unpair/Rest/Unpair+Reset with confirmation dialogs, nukeAllTables(), preferences clear
  • #131 Sleep hypnogram alignment + press-and-hold scrubber with stage readout pill
  • #99 Full-data JSON export/import — 27 @Serializable DTOs, SAF write/read, atomic wipe-and-restore

Tier 2 — Medium features

  • #98 Daily calorie estimation — Mifflin-St Jeor BMR, Keytel HR-gated, cadence-tiered step MET, recompute triggers
  • #95 HR zone thresholds — 3 modes (Standard/Auto/Custom), resting-HR baseline learning (p10/30d)
  • #94 Background syncs — StaleDataPolicy freshness window for coach notifications
  • #100 Strava integration — OAuth, TCX builder (GPS-HR merge), upload service, sport type mapping, settings UI

Tier 3 — Large features

  • #96 Calorie/macro nutrition tracking — Meal logging, day view with calorie gauge + macro bars, intake goals, settings
  • #130 RWfit ring family — dual 0x7E/0xAB protocol, full metric decoder, catalog entry + coordinator

Already-have / skip

  • #97 R10M YCBT (port of PulseLoopAndroid#31)
  • 160c775 Version 2.5.0 (Android uses BuildConfig.VERSION_NAME)

Stats

  • 27 new files, 45 modified
  • ~3,100 lines added
  • DB versions 16→20 (4 migrations)
  • 794 tests passing

…xport/import

#73 Privacy & Data Reset (PORT)
- PrivacyDataSettingsScreen: Unpair Ring, Reset App Data, Unpair & Reset with
  confirmation dialogs matching iOS
- PulseLoopDatabase.nukeAllTables() clears all 23 tables atomically
- Reset also clears SharedPreferences (pulseloop_secure/ring_ble/pulseloop_prefs)
  and navigates to onboarding
- Navigation updated to pass coordinator/bleClient/onNavigateToOnboarding

#131 Sleep hypnogram alignment + press-and-hold scrubber (ADAPT)
- Lane labels now share laneFraction math with Canvas bars (shared plotInsets)
- Press-and-hold gesture (detectDragGesturesAfterLongPress) with vertical scrub
  indicator line and stage-readout pill (DEEP · 3:05 – 3:51 AM)
- Haptic feedback on activation and block boundary crossing

#99 Data export/import (PORT)
- 27 @serializable DTOs + PulseArchive envelope in DataArchive.kt
- DataArchiveService: cursor-based bulk export to JSON via FileProvider share,
  import with nukeAllTables + batch DAO inserts, SAF file picker
- UI: Export/Import buttons with progress spinners, Replace-all-data confirmation
  dialog, success/error alerts in PrivacyDataSettingsScreen
- Sync ledger updated to 88c0f6b (2026-08-08, 11 new items triaged)
…ta policy

#98 Daily calorie estimation (PORT)
- DailyCalorieEstimator: Mifflin-St Jeor BMR, Keytel HR-gated (60% HRmax),
  cadence-tiered step MET, effectiveCalories/effectiveActiveCalories
- ActivityDailyEntity.estimatedActiveCalories field + DB migration 16→17
- Recompute trailing 7 days on sync completion (EventPersistenceSubscriber)
- WorkoutMetricsEngine.keytelCalorieRate made public for reuse

#95 HR zone thresholds + resting-HR baseline (PORT)
- VitalsThresholdEngine.heartRateZones: 3 modes (standard/auto/custom)
  with configurable boundaries from profile
- RestingHRBaselineService: p10 over 30-day HR history, ≥20 samples
  spanning ≥7 days, 6h refresh throttle, rounded to 0.5 bpm
- UserProfileEntity: hrZoneModeRaw, hrRestingBaseline, hrCustom* fields
- DB migration 17→18 + UserPhysiologyProfile extended

#94 StaleDataPolicy (ADAPT)
- Added STALE_DATA_WINDOW_MS to CoachNotificationWorker for broader
  staleness gating beyond the existing sync-freshness window
#100 Strava integration
- StravaAuth: OAuth authorize URL + code exchange + token refresh
- StravaTokenStore: EncryptedSharedPreferences-backed token persistence
- StravaTCXBuilder: ISO8601 XML with GPS trackpoints + HR merge (max 60s
  staleness), indoor fallback with HR-only trackpoints
- StravaUploader: multipart upload, duplicate detection via regex,
  15-attempt polling, sport_type fix for non-run/cycle types
- StravaSportMapping: activity type to Strava sport_type
- StravaSettingsScreen: connect/disconnect, manual sync, auto-upload card
- ActivitySessionEntity.stravaActivityId + DB migration 18→19
…ng family

#96 Calorie/macro nutrition tracking (PORT)
- NutritionEntities: MealEntryEntity + CachedFoodProductEntity (Room tables)
- MealEntryDao + FoodProductDao with dayTotals, search, LRU caching
- NutritionScreen: day navigation, calorie gauge, macro progress bars,
  meals grouped by type with delete
- MealLogDialog: manual entry with name, meal type chips, kcal/macros
- NutritionSettingsScreen: master toggle, intake goal steppers,
  Balance Macros (30/40/30) button
- UserGoalEntity extended with intakeCalories/ProteinG/CarbsG/FatG/nutritionEnabled
- DB migration 19→20: meal_entries + food_products tables
- Navigation: settings/nutrition + nutrition routes

#130 RWfit ring family (ADAPT)
- RWfitProtocol: dual 0x7E/0xAB framing, XOR checksum + CRC-16/ARC,
  command catalog, pack7e/packAb assembly
- RWfitDecoder: frame assembly, 12 metric decoders (HR/SpO2/steps/sleep/
  HRV/temp/stress/BP/glucose/battery/device-info/history)
- RWfitEncoder: handshake, live measurement toggles, time sync, unbind
- RWfitDriver: WearableDriver impl with protocol selection
- RWfitSyncEngine: RingSyncEngine impl
- RWfitCoordinator: service-UUID + name-based advertisement matching
- RingDeviceType.RWFIT + WearableModel.RWFIT + coordinator registration
- PairingMatchingTest: add RWFIT to registered coordinator types
- VitalsThresholdEngineTest: heartRateBoundaries + zoneThresholds updated
  for new auto-mode defaults (50/90 instead of old 60/100)

@foureight84 foureight84 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial code review — iOS sync (2026-08-08)

Showstoppers

1. Hardcoded Strava API secrets (StravaAuth.kt:19-20)
CLIENT_ID and CLIENT_SECRET are compile-time constants. These are trivially extractable from the APK, are now burned by appearing in this diff, and Strava rejects them anyway (/oauth/token returns "code":"invalid"). The iOS side solves this with StravaSecrets.plist (gitignored, each developer provides their own). The Android port has no equivalent — no config field, no file loader, no "not configured" fallback.

2. OAuth flow is non-functional

  • Uses web auth endpoint /oauth/authorize instead of Android's mobile endpoint /oauth/mobile/authorize
  • No intent filter to capture the pulseloop:// redirect → the code is lost on callback
  • StravaAuth.exchangeCode() is never called anywhere
  • state is generated but never validated (CSRF)
  • The UI tells users "restart the app to complete the connection" because there's no callback capture

3. Import is not atomic (DataArchiveService.kt:486)
nukeAllTables() runs outside a transaction, then DAO inserts follow. A process kill mid-import leaves a permanently corrupted database. Needs a single transaction wrapping the entire restore.

4. pollUntilDone is a no-op (StravaUploader.kt:2251)
Empty function body {}. Strava uploads return immediately but processing continues async — calling fixSportType() on an in-flight activity will silently fail.

High

5. Multiple stacked AlertDialogs (SettingsSubScreens.kt) — five dialog conditions fire independently, any subset can render simultaneously. Should be a sealed class state machine or if/else if.

6. Data loss: wearableLogs export→import roundtrip — export synthesizes event from categoryRaw/levelRaw/message; import hardcodes categoryRaw = "CONNECTION", levelRaw = "INFO". Original categories and levels are lost.

7. Dead imports (SleepScreen.kt) — SimpleDateFormat, Date, Locale, abs all imported but unused.

8. NutritionSettingsScreen dead button — "Open Nutrition Log" has an empty onClick.

9. Toggling Nutrition on overwrites existing user goalsgoal = g.copy(nutritionEnabled = enabled) creates a fresh UserGoalEntity with all defaults, nuking existing step/sleep/activeMinutes targets.

Medium

10. New OkHttpClient per HTTP call — created in exchangeCode, refreshToken, authenticatedRequest (×2 on 401). Should be a shared singleton.

11. performUnpairAndReset clears devices redundantlynukeAllTables() already includes the devices table.

12. RWfitDecoder never validates checksums/CRC — corrupted BLE frames produce silently-wrong health data.

13. appVersion hardcoded to "android-unknown" — exported archives carry no version, making compatibility checks impossible.

14. STALE_DATA_WINDOW_MS defined but unused (CoachNotifications.kt).

Architecture

The Strava integration should follow the Android guide the same way the iOS side follows its own platform guide — not mirror iOS mechanics:

Element Android spec This PR
Auth endpoint /oauth/mobile/authorize /oauth/authorize
Redirect URI HTTPS app link with intent filter pulseloop:// with no filter
Deep link Strava app via Intent fallback Raw browser
Credentials Per-developer API registration One hardcoded pair
State Generated & validated Generated, never checked

Summary

The nutrition, calorie estimation, HR zones, RWfit, and sleep scrubber code looks solid. The Strava integration and data import/export need fixes before merge.

DataArchiveService: wrap nuke+inserts in single transaction, preserve
categoryRaw/levelRaw on wearable log roundtrip, use BuildConfig.VERSION_NAME.

Strava: switch to /oauth/mobile/authorize (Android docs), move secrets to
BuildConfig via local.properties, shared OkHttpClient, add intent-filter
for pulseloop:// redirect, handle OAuth callback in MainActivity, implement
pollUntilDone(), add isConfigured gate.

SettingsSubScreens: when chain replaces stacked AlertDialogs, nutrition
toggle guarded against null goal, dead Open Nutrition Log wired, redundant
deviceDao().clear() removed.

SleepScreen: remove 4 unused imports.

CoachNotifications: wire STALE_DATA_WINDOW_MS into stale-data check.

versionCode 33 → 34
@foureight84

Copy link
Copy Markdown
Owner Author

versionName bumped to 2.5.0 to match the PulseLoop iOS App Store marketing version (MARKETING_VERSION = 2.5.0).

High:
- StravaUploader.pollUntilDone: log errors instead of silently swallowing
- MainActivity.handleStravaRedirect: use lifecycleScope instead of unmanaged CoroutineScope
- StravaUploader.uploadAuto: continue on failure instead of breaking loop

Medium:
- StravaSettingsScreen polling: cap at 60s deadline, break on token detection
- RWfitDecoder: add comments noting checksum validation is deferred
- NutritionSettingsScreen: null-guard Balance Macros button
- SleepScreen: add plotWidthPx to pointerInput key to prevent race with onSizeChanged

Low:
- StravaSportMapping/TCXBuilder: document why sport names differ between functions
- StravaSettingsScreen: remove unnecessary remember{} on isConfigured
- NutritionScreen: clarify day navigation direction
- SettingsSubScreens: fix invalid return@when labels (pre-existing from CR remediation)
…, archive, Strava

DailyCalorieEstimator: fix broken intervalSeconds (distance*600 → fixed 900s
buckets), add net-of-resting BMR and net-of-1-MET calculations to match iOS
DailyCalorieMath.estimateNetActive.

DataArchive DTOs: add 8 missing HR zone fields to UserProfileDTO
(hrZoneModeRaw/hrRestingBaseline/hrCustom*) and stravaActivityId to
ActivitySessionDTO so export→import preserves HR config and Strava linkage.

DataArchiveService: stop deleting meal_entries/food_products on import
(Android-only bug — iOS SwiftData leaves them untouched). Export and import
the new UserProfile and ActivitySession fields.

StravaAuth: add CSRF state parameter (UUID generated, stored, validated on
callback via validateState()), matching iOS StravaAuthService. Add
single-flight token refresh dedup via Mutex.

StravaUploader: pollUntilDone returns the final activityId (was fire-and-
forget void). uploadAuto breaks on failure (was continue — matches iOS
contiguous-advance watermark). HR query capped at session.endedAt.

MainActivity: validate Strava state parameter on redirect callback.
The RWfit port was not derived from the vendor app. iOS RWfitProtocol.swift
cites com.rw.revivalfit file-by-file; the same decompile sits at the repo root
as decompiled-rwfit-official/. Every wire constant in the Android version was
invented instead:

- write/notify characteristics a002/a003 vs the vendor's b002/b003, so the
  driver could never write to a real ring
- legacy 0x7E frame `7E len cmd payload xor` vs the vendor's
  `7E 01 cmd flags dataLen serHi serLo xor payload`
- synthetic 0x01..0x0C/0x10/0x11 command table vs deviceInfo 0x00, battery
  0x01, setTime 0x21, history 0xA0-0xA7, unbind 0x44
- no 0xFE/0xFF ACK handshake; JieLi triples always {cmd,0,0}; framing never
  selected from the sibling AE00/Telink/FF00 services, so the 0xAB path is
  unreachable
- name-prefix matching ("RW"), which iOS rejects on purpose in favour of the
  A00A advertisement + manufacturer prefixes
- all 13 capabilities granted unconditionally instead of iOS's baseline +
  bitmap-gated split

Plus logic bugs found in the same pass: decodeStress' `and 0xFF.coerceIn(0,100)`
precedence bug (masks with 0x64), decodeSleep discarding its decode and
returning empty stages, a bogus Status event per unknown frame, a sync engine
that never requests history or syncs time, and 0xAB deframing that assumes the
whole buffer is one frame.

Removes the six RWfit*.kt files, RingDeviceType.RWFIT, WearableModel.RWFIT and
its catalog entry, the coordinator registration, the DeviceHeroCard arm and the
PairingMatchingTest entry; restores the CRP ordering comment that the port had
deleted. Work is preserved on feat/rwfit-ring-family for a rebuild against
decompiled-rwfit-official/, to be recombined afterwards.

794 tests still pass.
…guard

#99 import deadlock. importFile opened a raw
openHelper.writableDatabase.beginTransaction() on Dispatchers.Default and then
called suspend Room DAOs, which hop to Room's query dispatcher and block on the
write connection the suspended thread is holding. Now db.withTransaction {},
which installs the TransactionElement that keeps those DAO calls on the
transaction's own dispatcher — and routes through RoomDatabase.endTransaction(),
which is what fires the invalidation tracker, so observing Flows refresh after a
restore. nukeAllTables() moved to withTransaction for the same reason. Dropped
the PRAGMA foreign_keys toggle: SQLite makes it a no-op inside a transaction,
and the delete order is already parent-first.

#99/#96 round-trip data loss.
- ActivityDailyDTO.estimatedActiveCalories was declared but never exported or
  imported.
- UserGoalDTO dropped all five nutrition fields, so a restore wiped intake goals
  and switched nutrition off.
- wearableLogs packed "CATEGORY/LEVEL: message" into `event` and the importer
  assigned it back to `message`, re-prefixing on every cycle. `event` is now the
  message; category and level travel in their own fields.
- meal_entries/food_products were wiped on import but never restored. They are
  now exported and restored (iOS's DataArchive.swift omits them — Android
  originated, upstream candidate).
- The wipe list and nukeAllTables were separate literals that had already
  drifted by two tables; both now use PulseLoopDatabase.ALL_TABLES.

#98 calorie estimator.
- effectiveCalories/effectiveActiveCalories inverted iOS's
  deviceReportedCalories: they returned the row's calories only when source ==
  "ring_history" (the one case iOS excludes, since ring history calories are
  dropped as unverified) and ignored genuine device values from every other
  source. Both directions were wrong.
- Nothing called either function, so the whole feature was inert. TodayViewModel
  now reads through effectiveCalories.
- Added the three missing terms: workout windows (prorated, net of BMR), overlap
  accounting so HR-covered minutes aren't paid for twice by step buckets, and
  the residual-steps term that makes live-only days work.
- HR path now requires sex ∈ {male, female} + age + weight like iOS; it was
  running with sex == null and silently using the female Keytel form.
- recompute() no longer inserts an activity_daily row when none exists — it was
  fabricating up to 7 phantom zero-step ring_history days per sync.

#95 resting-HR baseline. RestingHRBaselineService had no callers, so
hrRestingBaseline was always null and the default "auto" zone mode always took
the no-baseline fallback — the feature moved everyone's normal band to 50/90
without delivering the personalisation. Now called on sync completion
(self-throttled to 6h). physiologyProfile() also dropped every HR-zone field on
the way from UserProfileEntity to UserPhysiologyProfile, so the engine could
never see the mode, the baseline or the custom boundaries; it now takes the
whole entity. Distinct-day counting switched from UTC to local days.

#94 stale-data guard. STALE_DATA_WINDOW_MS (1h) was evaluated only after the 3h
RECENT_DATA_WINDOW_MS early-return, so dataIsStale was always true and
`if (!dataIsStale && isAppForeground()) return` never fired — silently deleting
the foreground guard and letting the worker open a second transient GATT client
while the foreground app held the link. Guard restored, constant removed, and
the comment now records what iOS #94 actually does (CoachNotificationDataTrigger,
not a window constant).

794 tests pass.
… without

Strava OAuth (validated against developers.strava.com/docs/authentication).
- CSRF state is persisted in the token store, not held in a field. The flow
  leaves for the browser or the Strava app and Android may kill the process
  while it is gone — which is why MainActivity has a cold-start handler at all.
  An in-memory state was always null on that path, so every process-death
  authorization was silently dropped.
- Handle error=access_denied, which the docs say is how a declined consent comes
  back; it was ignored.
- Verify the granted scope contains activity:write. The consent screen lets the
  user untick "Upload your activities", and without the check the connect looks
  fine and every later upload 401s with no explanation.
- Single-flight refresh now re-reads the store inside the mutex. Strava rotates
  the refresh token, so serialising two callers wasn't enough — the second still
  presented the token the first had already burned.
- deauthorize() on disconnect, so PulseLoop also leaves the athlete's "My Apps".
- Failures are recorded for the settings screen instead of vanishing; the
  callback lands in MainActivity, so there was nowhere for the user to see them.
- "Reset App Data" now wipes pulseloop_strava. It is its own
  EncryptedSharedPreferences file, so the clear-preferences loop missed it and a
  factory reset left the account connected.

TCX.
- <Id> is an ISO-8601 dateTime; it was emitting epoch seconds, which the schema
  types as xsd:dateTime.
- <TriggerMethod> added — required by the ActivityLap schema.
- TotalTimeSeconds subtracts totalPauseSeconds instead of reporting wall clock.
- build() returns null when there are no trackpoints, and the uploader falls
  back to POST /activities. Before, a workout with no GPS and no HR uploaded an
  empty <Track> and was rejected.
- Trackpoint dropping inside pause intervals (plumbed and tested; the uploader
  passes none because Android never writes activity_events — totalPauseSeconds
  is the part that applies today).
- Locale-independent number formatting, rounded HR summaries.

Uploader.
- Sends name ("Morning Run"), description and the trainer flag.
- uploadAuto no longer back-fills: sessions that finished before the account was
  connected are skipped (iOS automaticSince), so connecting Strava can't push 20
  old workouts to a public feed. A failure stops the pass for retry rather than
  leapfrogging.
- Auto-upload is actually wired, from LiveWorkoutManager.finish. It previously
  had one caller — the manual "Sync Now" button — while the settings card
  claimed workouts upload automatically.
- Errors are typed and surfaced instead of collapsing to null; 429 is called out
  as rate limiting; polling checks the initial response before sleeping.
- toStravaType no longer declares a null it never returns; needsSportTypeFix
  replaces the duplicated run/cycle test in fixSportType.

Sleep hypnogram (#131).
- Scrub indicator moved out of the per-block loop, so later bars stop painting
  over it; also drops an O(n^2) sorted.indexOf that resolved duplicate blocks to
  the same index.
- Readout pill is clamped to both edges (it ran off-screen at the end of the
  night) and no longer divides then multiplies by plotWidthPx, which was a no-op
  that produced NaN at zero width.
- Lane labels wait for the first measurement instead of stacking at y=-7dp.
- Replaced the px→dp→px offset round-trips with named constants.

Adds StravaTCXBuilderTest (12) and StravaAuthTest (6), mirroring iOS's
StravaTCXBuilderTests/StravaAuthServiceTests. 812 tests pass.
Adds the session-notes section referenced by the RESUME HERE block: the
wired-up-but-inert pattern behind #98/#95/#96, the correctness table, the
Strava-vs-official-docs validation, and what is still open (#94's real
CoachNotificationDataTrigger, #96's subset scope, pause intervals). Corrects
#96's verdict from PORT to ADAPT (subset).
@foureight84
foureight84 merged commit 3e3b9a0 into main Aug 9, 2026
1 check passed
@foureight84
foureight84 deleted the ios_sync_2026-08-08 branch August 9, 2026 04:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant