iOS sync (2026-08-08): port 8 PRs across Tiers 1–3 - #45
Conversation
…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
left a comment
There was a problem hiding this comment.
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/authorizeinstead 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 anywherestateis 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 goals — goal = 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 redundantly — nukeAllTables() 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
|
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).
iOS → Android sync — 2026-08-08 triage
Ports 8 iOS PRs from PulseLoopiOS main since 2026-07-18 (commit
0d1b965→88c0f6b). 3 additional items verified as already-have/skip.Tier 1 — Quick wins
nukeAllTables(), preferences clear@SerializableDTOs, SAF write/read, atomic wipe-and-restoreTier 2 — Medium features
StaleDataPolicyfreshness window for coach notificationsTier 3 — Large features
Already-have / skip
160c775Version 2.5.0 (Android usesBuildConfig.VERSION_NAME)Stats