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: 2 additions & 2 deletions apps/ios/Vitals.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -786,7 +786,7 @@
GENERATE_INFOPLIST_FILE = "NO";
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon";
MARKETING_VERSION = "0.1";
CURRENT_PROJECT_VERSION = "10";
CURRENT_PROJECT_VERSION = "12";
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks",);
};
name = "Debug";
Expand All @@ -807,7 +807,7 @@
GENERATE_INFOPLIST_FILE = "NO";
ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon";
MARKETING_VERSION = "0.1";
CURRENT_PROJECT_VERSION = "10";
CURRENT_PROJECT_VERSION = "12";
LD_RUNPATH_SEARCH_PATHS = ("$(inherited)", "@executable_path/Frameworks",);
};
name = "Release";
Expand Down
15 changes: 6 additions & 9 deletions apps/ios/Vitals/Services/HelioSleepArchive.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,13 @@ enum HelioTransferError: Error {

func message(for type: ZeppFetchType) -> String {
switch self {
case .unverified: return "the strap’s transfer couldn’t be verified. pull down to retry."
case .stageFormat: return "Motion couldn’t read this strap’s sleep-stage format."
case .sleepDates: return "the strap returned a sleep record with unexpected dates."
case .stageBounds: return "the strap returned stages outside its recorded sleep period."
case .stageOverlap: return "the strap returned overlapping sleep stages."
case .unknownStages: return "this sleep record has no stages Motion recognizes."
case .unverified: return "Motion couldn’t finish downloading from your strap. try syncing again."
case .stageFormat, .sleepDates, .stageBounds, .stageOverlap, .unknownStages:
return "Motion couldn’t read some sleep data from your strap. try syncing again."
case .readingDates:
let metric = type == .activity ? "heart-rate" : "breathing"
return "the strap returned \(metric) readings with unexpected dates."
case .unsupportedRecord: return "Motion couldn’t read this type of strap record."
let metric = type == .activity ? "heart rate" : "breathing"
return "Motion couldn’t read some \(metric) data from your strap. try syncing again."
case .unsupportedRecord: return "Motion couldn’t read some data from your strap. try syncing again."
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions apps/ios/Vitals/Services/HelioSleepSync.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ import Observation
func sync(force: Bool = false) {
#if DEBUG
let args = ProcessInfo.processInfo.arguments
if args.contains("-ui-testing"), args.contains("-ui-testing-sleep-sync-error") {
let issue = HelioTransferError.stageBounds(startOffset: -120, endOffset: -360, duration: 240)
status = "sync incomplete"
message = issue.message(for: .sleepSession)
diagnosticDetails = issue.diagnosticDetails
return // Synthetic failure without Bluetooth, Keychain or archive writes.
}
if !force, args.contains("-ui-testing"), args.contains("-ui-testing-strap") { return }
#endif
// Files/Keychain can be unavailable during a Bluetooth relaunch before first unlock.
Expand Down
29 changes: 18 additions & 11 deletions apps/ios/Vitals/UI/SleepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ struct SleepTab: View {
@State private var period: SleepPeriod = .daily
@State private var date = Calendar.current.startOfDay(for: Date.now)
@State private var selectedTime: Date?
@State private var showingSyncIssue = false
private var syncIssue: String? {
let messages = [helio.message, health.message].compactMap { $0 }
return messages.isEmpty ? nil : messages.joined(separator: "\n\n")
}
private var measurements: SleepMeasurements {
SleepSummary.combined(
helio: SleepMeasurements(sessions: helio.archive.sleepSessions(endingOn: date),
Expand Down Expand Up @@ -42,6 +47,13 @@ struct SleepTab: View {
ProgressView()
Text(helio.status).font(VitalsStyle.caption).foregroundStyle(VitalsStyle.secondary)
}
} else if syncIssue != nil {
Button { showingSyncIssue = true } label: {
Label("sync incomplete", systemImage: "exclamationmark.circle")
.font(VitalsStyle.caption)
}
.foregroundStyle(VitalsStyle.secondary)
.accessibilityIdentifier("sleep-sync-issue")
}
if period == .weekly, !week.isEmpty {
weeklyCharts(data, week: week)
Expand All @@ -65,17 +77,6 @@ struct SleepTab: View {
}
.accessibilityIdentifier("sleep-empty")
}
if let message = helio.message {
Text(message).font(VitalsStyle.caption).foregroundStyle(VitalsStyle.caution)
.contextMenu {
if let details = helio.diagnosticDetails {
Button("copy sync details") { UIPasteboard.general.string = details }
}
}
}
if let message = health.message {
Text(message).font(VitalsStyle.caption).foregroundStyle(VitalsStyle.caution)
}
}
.padding(VitalsStyle.gutter)
}
Expand All @@ -97,6 +98,12 @@ struct SleepTab: View {
}.quietBackground()
}
}
.alert("sync incomplete", isPresented: $showingSyncIssue) {
Button("try again") { Task { await reload(force: true) } }
Button("close", role: .cancel) {}
} message: {
Text(syncIssue ?? "try syncing again.")
}
.task(id: date) {
selectedTime = nil
if health.usesTestMeasurements { requested = true }
Expand Down
17 changes: 9 additions & 8 deletions apps/ios/VitalsTests/HelioSleepTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ final class HelioSleepTests: XCTestCase {
retry.device.fetchData[.sleepSession] = (start, outside + napBytes())
installReadings(retry)
retry.run()
XCTAssertTrue(retry.sync.message?.contains("outside its recorded sleep period") == true)
XCTAssertEqual(retry.sync.message, "Motion couldn’t read some sleep data from your strap. try syncing again.")
XCTAssertEqual(retry.sync.diagnosticDetails,
"sleep stage bounds; start offset: -120 min; end offset: -360 min; stage duration: 240 min")
XCTAssertEqual(retry.sync.archive.sessions.count, 2)
Expand Down Expand Up @@ -195,27 +195,28 @@ final class HelioSleepTests: XCTestCase {
@MainActor func testUnsupportedSleepPreservesNightAndCursorButStillFetchesOtherMetrics() throws {
let directory = try directory(), baseline = rig(directory)
installSleep(baseline); baseline.run()
var invalidCases: [(bytes: [UInt8], message: String)] = []
var invalidCases: [(bytes: [UInt8], detail: String)] = []
var overflow = sleepBytes(); overflow[0x54] = 101
invalidCases.append((overflow, "sleep-stage format"))
invalidCases.append((sleepBytes(invalid: true), "outside its recorded sleep period"))
invalidCases.append((overflow, "stageFormat"))
invalidCases.append((sleepBytes(invalid: true), "sleep stage bounds"))
var overlap = sleepBytes()
overlap.replaceSubrange(0x5b..<0x5d, with: le(1439, count: 2))
invalidCases.append((overlap, "overlapping sleep stages"))
invalidCases.append((overlap, "stageOverlap"))
var future = sleepBytes()
future.replaceSubrange(0..<4, with: le(UInt32(Date.now.timeIntervalSince1970 + 86_400)))
invalidCases.append((future, "unexpected dates"))
invalidCases.append((future, "sleepDates"))
var unknown = sleepBytes()
for index in 0..<3 { unknown[0x5a + index * 5] = 0x02 }
invalidCases.append((unknown, "no stages Motion recognizes"))
invalidCases.append((unknown, "unknownStages"))
for invalid in invalidCases {
let broken = rig(directory)
broken.device.fetchData[.sleepSession] = (ZeppFetchTimestamp.encode(midnight, timeZone: TimeZone(secondsFromGMT: 0)!), invalid.bytes)
installReadings(broken)
broken.run()
XCTAssertFalse(broken.sync.busy)
XCTAssertTrue(broken.sync.hasKey)
XCTAssertTrue(broken.sync.message?.contains(invalid.message) == true, broken.sync.message ?? "missing message")
XCTAssertEqual(broken.sync.message, "Motion couldn’t read some sleep data from your strap. try syncing again.")
XCTAssertTrue(broken.sync.diagnosticDetails?.contains(invalid.detail) == true, broken.sync.diagnosticDetails ?? "missing diagnostics")
XCTAssertEqual(broken.sync.archive.sessions.flatMap(\.segments), baseline.sync.archive.sessions.flatMap(\.segments))
XCTAssertEqual(broken.sync.archive.cursors["72"], baseline.sync.archive.cursors["72"])
XCTAssertEqual(broken.sync.archive.lastSync, baseline.sync.archive.lastSync)
Expand Down
48 changes: 48 additions & 0 deletions apps/ios/VitalsUITests/WorkoutFlowUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,54 @@ final class WorkoutFlowUITests: XCTestCase {
capture(app, "weekly schedule keeps gaps between periods")
}

@MainActor func testSleepSyncIssueIsCompactAndKeepsChartsVisible() {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-ui-testing", "-ui-testing-sleep", "-ui-testing-strap", "-ui-testing-sleep-sync-error"]
app.launch()
XCTAssertTrue(app.buttons["tab-sleep"].waitForExistence(timeout: 15))
app.buttons["tab-sleep"].tap()
let issue = app.buttons["sleep-sync-issue"]
XCTAssertTrue(issue.waitForExistence(timeout: 5))
XCTAssertEqual(app.staticTexts["sleep-duration"].label, "7h 42m")
XCTAssertLessThan(issue.frame.maxY, app.staticTexts["sleep-duration"].frame.minY)
let explanation = "Motion couldn’t read some sleep data from your strap. try syncing again."
XCTAssertFalse(app.staticTexts[explanation].isHittable, "the hidden settings page must not expose details on Sleep")
issue.tap()
let alert = app.alerts["sync incomplete"]
XCTAssertTrue(alert.waitForExistence(timeout: 5))
XCTAssertTrue(alert.staticTexts[explanation].exists)
XCTAssertTrue(alert.buttons["try again"].exists)
capture(app, "plain sync explanation")
alert.buttons["close"].tap()
app.swipeUp()
XCTAssertFalse(app.staticTexts[explanation].isHittable)
XCTAssertFalse(app.staticTexts["the strap returned stages outside its recorded sleep period."].exists)
XCTAssertFalse(app.buttons["copy sync details"].exists)
capture(app, "sleep charts without error footer")
app.segmentedControls["sleep-period"].buttons["weekly"].tap()
XCTAssertTrue(issue.isHittable)
XCTAssertTrue(app.otherElements["sleep-weekly-duration-chart"].firstMatch.exists)
capture(app, "weekly sleep with compact sync status")
}

@MainActor func testEmptySleepStillExplainsSyncFailureAndOffersRetry() {
continueAfterFailure = false
let app = XCUIApplication()
app.launchArguments = ["-ui-testing", "-ui-testing-sleep", "-ui-testing-sleep-empty", "-ui-testing-sleep-sync-error"]
app.launch()
XCTAssertTrue(app.buttons["tab-sleep"].waitForExistence(timeout: 15))
app.buttons["tab-sleep"].tap()
XCTAssertTrue(app.staticTexts["no sleep measurements for this date"].waitForExistence(timeout: 5))
app.buttons["sleep-sync-issue"].tap()
XCTAssertTrue(app.alerts["sync incomplete"].waitForExistence(timeout: 5))
app.alerts["sync incomplete"].buttons["try again"].tap()
XCTAssertTrue(app.buttons["sleep-sync-issue"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["no sleep measurements for this date"].exists)
XCTAssertFalse(app.staticTexts["sleep-duration"].exists)
capture(app, "empty sleep keeps actionable sync status")
}

@MainActor func testStrapSyncIsBesideSettingsAndSetupOnlyManagesKey() {
continueAfterFailure = false
let app = XCUIApplication()
Expand Down
3 changes: 2 additions & 1 deletion apps/ios/docs/helio-sleep.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ that account's band keys and local history. Signing out preserves its isolated c
stage intervals reject that record without advancing the sleep cursor.
Motion then continues with independent heart-rate and breathing downloads. Content
errors identify the reason separately from failures writing the local archive.
Long-press a sync error in Settings or Sleep to **copy sync details**. These
Sleep shows a compact **sync incomplete** status above the charts. Tap it for a plain-language explanation and retry; diagnostics are never printed below the charts.
Long-press a sync error in Settings to **copy sync details**. These
diagnostics contain relative minute offsets only, with no key, packets, absolute
timestamps, account identity or heart/breathing readings; nothing is uploaded.
- Every fetch acknowledgement uses **keep on device** (`03 09`), including failures.
Expand Down
2 changes: 1 addition & 1 deletion apps/ios/scripts/generate-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# Keep the registered bundle identifier stable across the motion rebrand and TestFlight updates.
BUNDLE_IDENTIFIER = 'dev.gtfol.vitals'
MARKETING_VERSION = '0.1'
CURRENT_PROJECT_VERSION = '10'
CURRENT_PROJECT_VERSION = '12'

objects = {}
def uid(name): return hashlib.sha256(name.encode()).hexdigest()[:24].upper()
Expand Down
Loading