From 4515abe813e89fc938de68bf1d0dc0782b02e7bb Mon Sep 17 00:00:00 2001 From: Ramazan TUNCER Date: Sat, 26 Sep 2026 22:48:38 +0300 Subject: [PATCH] Fix launch crash on non-string Nightscout treatment timestamps Treatment parsers force-cast `timestamp`/`created_at` to String after a `!= nil` check. A treatment carrying a JSON null or numeric timestamp passes the nil check and traps in swift_dynamicCast on the main queue, so the app aborts on every launch while that record is within the download window. Add NightscoutUtils.treatmentDate(from:) which accepts ISO 8601 strings, epoch milliseconds/seconds, and falls back to created_at on null; use it in the bolus, SMB, carb, note and stats parsers. Replace the remaining forced casts in device status, SAge, CAge and IAge handling with conditional binding so malformed data is skipped instead of crashing. Co-Authored-By: Claude Fable 5.1 --- LoopFollow/Controllers/Nightscout/CAge.swift | 6 ++- .../Nightscout/DeviceStatusLoop.swift | 12 +++-- .../Nightscout/DeviceStatusOpenAPS.swift | 4 +- LoopFollow/Controllers/Nightscout/IAge.swift | 2 +- LoopFollow/Controllers/Nightscout/SAge.swift | 10 ++-- .../Nightscout/Treatments/Bolus.swift | 11 +--- .../Nightscout/Treatments/Carbs.swift | 11 +--- .../Nightscout/Treatments/Notes.swift | 11 +--- .../Nightscout/Treatments/SMB.swift | 11 +--- LoopFollow/Helpers/NightscoutUtils.swift | 26 +++++++++ LoopFollow/Stats/StatsDataFetcher.swift | 33 ++---------- Tests/NightscoutTreatmentDateTests.swift | 53 +++++++++++++++++++ 12 files changed, 110 insertions(+), 80 deletions(-) create mode 100644 Tests/NightscoutTreatmentDateTests.swift diff --git a/LoopFollow/Controllers/Nightscout/CAge.swift b/LoopFollow/Controllers/Nightscout/CAge.swift index 7e20bfade..ae02c46b4 100644 --- a/LoopFollow/Controllers/Nightscout/CAge.swift +++ b/LoopFollow/Controllers/Nightscout/CAge.swift @@ -39,7 +39,11 @@ extension MainViewController { .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] - Storage.shared.cageInsertTime.value = formatter.date(from: lastCageString)?.timeIntervalSince1970 as! TimeInterval + guard let cageInsertTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 else { + LogManager.shared.log(category: .nightscout, message: "updateCage: unparseable created_at '\(lastCageString)'") + return + } + Storage.shared.cageInsertTime.value = cageInsertTime if let cageTime = formatter.date(from: lastCageString)?.timeIntervalSince1970 { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - cageTime diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift index bd5699726..423c807ff 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusLoop.swift @@ -64,9 +64,11 @@ extension MainViewController { latestCOB = cobMetric } - if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject] { - let prediction = predictdata["values"] as! [Double] - Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(prediction.last!)))) + if let predictdata = lastLoopRecord["predicted"] as? [String: AnyObject], + let prediction = predictdata["values"] as? [Double], + let lastPrediction = prediction.last + { + Observable.shared.predictionText.value = Localizer.toDisplayUnits(String(Int(round(lastPrediction)))) Observable.shared.predictionColor.value = .purple if Storage.shared.downloadPrediction.value, previousLastLoopTime < lastLoopTime || predictionData.isEmpty { predictionData.removeAll() @@ -108,7 +110,9 @@ extension MainViewController { Observable.shared.deviceRecBolus.value = nil } if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { - if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 { + if let tempBasalString = loopStatus["timestamp"] as? String, + let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 + { var lastBGTime = lastLoopTime if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date diff --git a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift index 7dfdb4cdb..f15bef344 100644 --- a/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift +++ b/LoopFollow/Controllers/Nightscout/DeviceStatusOpenAPS.swift @@ -220,7 +220,9 @@ extension MainViewController { } if let loopStatus = lastLoopRecord["recommendedTempBasal"] as? [String: AnyObject] { - if let tempBasalTime = formatter.date(from: (loopStatus["timestamp"] as! String))?.timeIntervalSince1970 { + if let tempBasalString = loopStatus["timestamp"] as? String, + let tempBasalTime = formatter.date(from: tempBasalString)?.timeIntervalSince1970 + { var lastBGTime = updatedTime ?? Date().timeIntervalSince1970 if bgData.count > 0 { lastBGTime = bgData[bgData.count - 1].date diff --git a/LoopFollow/Controllers/Nightscout/IAge.swift b/LoopFollow/Controllers/Nightscout/IAge.swift index 71909cc49..d1f28dac7 100644 --- a/LoopFollow/Controllers/Nightscout/IAge.swift +++ b/LoopFollow/Controllers/Nightscout/IAge.swift @@ -44,7 +44,7 @@ extension MainViewController { .withDashSeparatorInDate, .withColonSeparatorInTime] - if let iageTime = formatter.date(from: (lastIageString as! String))?.timeIntervalSince1970 { + if let iageTime = formatter.date(from: lastIageString)?.timeIntervalSince1970 { Storage.shared.iageInsertTime.value = iageTime let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - iageTime diff --git a/LoopFollow/Controllers/Nightscout/SAge.swift b/LoopFollow/Controllers/Nightscout/SAge.swift index 580d39f1a..596974e12 100644 --- a/LoopFollow/Controllers/Nightscout/SAge.swift +++ b/LoopFollow/Controllers/Nightscout/SAge.swift @@ -36,14 +36,18 @@ extension MainViewController { return } currentSage = data[0] - var lastSageString = data[0].created_at + let lastSageString = data[0].created_at let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate, .withTime, .withDashSeparatorInDate, .withColonSeparatorInTime] - Storage.shared.sageInsertTime.value = formatter.date(from: lastSageString)?.timeIntervalSince1970 as! TimeInterval + guard let sageInsertTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 else { + LogManager.shared.log(category: .nightscout, message: "updateSage: unparseable created_at '\(lastSageString)'") + return + } + Storage.shared.sageInsertTime.value = sageInsertTime // -- Auto-snooze CGM start ──────────────────────────────────────────────── let now = Date() @@ -62,7 +66,7 @@ extension MainViewController { } } - if let sageTime = formatter.date(from: (lastSageString as! String))?.timeIntervalSince1970 { + if let sageTime = formatter.date(from: lastSageString)?.timeIntervalSince1970 { let now = dateTimeUtils.getNowTimeIntervalUTC() let secondsAgo = now - sageTime diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift index 58314a4d9..9fe4ba7b1 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Bolus.swift @@ -11,16 +11,7 @@ extension MainViewController { var lastFoundIndex = 0 for currentEntry in entries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift index 23e2e0427..e9335efc4 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Carbs.swift @@ -13,18 +13,9 @@ extension MainViewController { var lastFoundSmb = 0 for currentEntry in entries.reversed() { - var carbDate: String - if currentEntry["timestamp"] != nil { - carbDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - carbDate = currentEntry["created_at"] as! String - } else { - continue - } - let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0 - guard let parsedDate = NightscoutUtils.parseDate(carbDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let carbs = currentEntry["carbs"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift index cc4435212..9615f53ce 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Notes.swift @@ -13,16 +13,7 @@ extension MainViewController { for currentEntry in entries.reversed() { guard let currentEntry = currentEntry as? [String: AnyObject] else { continue } - var date: String - if currentEntry["timestamp"] != nil { - date = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - date = currentEntry["created_at"] as! String - } else { - continue - } - - if let parsedDate = NightscoutUtils.parseDate(date) { + if let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry) { let dateTimeStamp = parsedDate.timeIntervalSince1970 let sgv = findNearestBGbyTime(needle: dateTimeStamp, haystack: bgData, startingIndex: lastFoundIndex) lastFoundIndex = sgv.foundIndex diff --git a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift index f896e4941..012bed356 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/SMB.swift @@ -10,16 +10,7 @@ extension MainViewController { var lastFoundIndex = 0 for currentEntry in entries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/LoopFollow/Helpers/NightscoutUtils.swift b/LoopFollow/Helpers/NightscoutUtils.swift index 07a6246bc..1fa6c5bdb 100644 --- a/LoopFollow/Helpers/NightscoutUtils.swift +++ b/LoopFollow/Helpers/NightscoutUtils.swift @@ -274,6 +274,32 @@ class NightscoutUtils { task.resume() } + /// Effective time of a Nightscout treatment: `timestamp` when present, otherwise `created_at`. + /// Uploaders disagree on the value's type (ISO 8601 string, epoch milliseconds, or JSON `null`), + /// so never force-cast it; an unreadable value yields `nil` and the entry is skipped. + static func treatmentDate(from entry: [String: AnyObject]) -> Date? { + for key in ["timestamp", "created_at"] { + guard let value = entry[key], !(value is NSNull) else { continue } + return parseDateValue(value) + } + return nil + } + + static func parseDateValue(_ value: Any) -> Date? { + switch value { + case let string as String: + return parseDate(string) + case let number as NSNumber: + // Booleans are NSNumbers too; they are never a date. + if CFGetTypeID(number) == CFBooleanGetTypeID() { return nil } + let raw = number.doubleValue + // Epoch milliseconds are ~1e12; epoch seconds are ~1e9. + return Date(timeIntervalSince1970: raw > 1e11 ? raw / 1000 : raw) + default: + return nil + } + } + static func parseDate(_ rawString: String) -> Date? { var mutableDate = rawString diff --git a/LoopFollow/Stats/StatsDataFetcher.swift b/LoopFollow/Stats/StatsDataFetcher.swift index 18353dce0..71f84b038 100644 --- a/LoopFollow/Stats/StatsDataFetcher.swift +++ b/LoopFollow/Stats/StatsDataFetcher.swift @@ -201,16 +201,7 @@ class StatsDataFetcher { var lastFoundIndex = 0 for currentEntry in bolusEntries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -250,16 +241,7 @@ class StatsDataFetcher { var lastFoundIndex = 0 for currentEntry in smbEntries.reversed() { - var bolusDate: String - if currentEntry["timestamp"] != nil { - bolusDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - bolusDate = currentEntry["created_at"] as! String - } else { - continue - } - - guard let parsedDate = NightscoutUtils.parseDate(bolusDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let bolus = currentEntry["insulin"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 @@ -296,18 +278,9 @@ class StatsDataFetcher { var lastFoundBolus = 0 for currentEntry in carbEntries.reversed() { - var carbDate: String - if currentEntry["timestamp"] != nil { - carbDate = currentEntry["timestamp"] as! String - } else if currentEntry["created_at"] != nil { - carbDate = currentEntry["created_at"] as! String - } else { - continue - } - let absorptionTime = currentEntry["absorptionTime"] as? Int ?? 0 - guard let parsedDate = NightscoutUtils.parseDate(carbDate), + guard let parsedDate = NightscoutUtils.treatmentDate(from: currentEntry), let carbs = currentEntry["carbs"] as? Double else { continue } let dateTimeStamp = parsedDate.timeIntervalSince1970 diff --git a/Tests/NightscoutTreatmentDateTests.swift b/Tests/NightscoutTreatmentDateTests.swift new file mode 100644 index 000000000..abfb74105 --- /dev/null +++ b/Tests/NightscoutTreatmentDateTests.swift @@ -0,0 +1,53 @@ +// LoopFollow +// NightscoutTreatmentDateTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct NightscoutTreatmentDateTests { + private typealias Entry = [String: AnyObject] + + private let expected = Date(timeIntervalSince1970: 1_787_000_000) + + @Test("parses an ISO 8601 timestamp string") + func parsesStringTimestamp() { + let entry: Entry = ["timestamp": "2026-08-17T20:53:20.000Z" as AnyObject] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("falls back to created_at when timestamp is missing") + func fallsBackToCreatedAt() { + let entry: Entry = ["created_at": "2026-08-17T20:53:20Z" as AnyObject] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("falls back to created_at when timestamp is JSON null") + func nullTimestampFallsBack() { + let entry: Entry = [ + "timestamp": NSNull(), + "created_at": "2026-08-17T20:53:20Z" as AnyObject, + ] + #expect(NightscoutUtils.treatmentDate(from: entry) == expected) + } + + @Test("accepts epoch milliseconds and seconds") + func parsesNumericTimestamp() { + let millis: Entry = ["timestamp": NSNumber(value: 1_787_000_000_000)] + let seconds: Entry = ["timestamp": NSNumber(value: 1_787_000_000)] + #expect(NightscoutUtils.treatmentDate(from: millis) == expected) + #expect(NightscoutUtils.treatmentDate(from: seconds) == expected) + } + + @Test("returns nil instead of trapping on unsupported values") + func unsupportedValuesReturnNil() { + let bool: Entry = ["timestamp": NSNumber(value: true)] + let array: Entry = ["timestamp": ["2026-08-17T20:53:20Z"] as AnyObject] + let garbage: Entry = ["timestamp": "not a date" as AnyObject] + let empty: Entry = [:] + #expect(NightscoutUtils.treatmentDate(from: bool) == nil) + #expect(NightscoutUtils.treatmentDate(from: array) == nil) + #expect(NightscoutUtils.treatmentDate(from: garbage) == nil) + #expect(NightscoutUtils.treatmentDate(from: empty) == nil) + } +}