From 2895d18293626fe6c85edf9a7bde29f5402aef44 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:48:36 +0200 Subject: [PATCH 1/2] Remove Dexcom Share follower management Followers can no longer be listed, invited or removed from the app. The legacy Share endpoints behind this can leave followers of the Dexcom app without updates. Followers set up through the Dexcom app are unaffected. Upload now posts readings first, and only signs in again, starts a monitoring session or registers the sensor as the account's device when the service refuses for that reason. This also stops uploads stalling after a sensor change until the app is relaunched. --- G7SensorKit.xcodeproj/project.pbxproj | 4 - G7SensorKit/G7CGMManager/G7CGMManager.swift | 11 - G7SensorKit/Share/G7ShareClient.swift | 155 +----------- G7SensorKit/Share/G7ShareUploader.swift | 90 ++++--- G7SensorKitTests/G7ShareTests.swift | 139 +++++++++-- G7SensorKitUI/Views/G7SettingsView.swift | 5 - G7SensorKitUI/Views/G7SettingsViewModel.swift | 4 - .../Views/Share/G7ShareFollowersView.swift | 222 ------------------ 8 files changed, 184 insertions(+), 446 deletions(-) delete mode 100644 G7SensorKitUI/Views/Share/G7ShareFollowersView.swift diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 6695356..94ccb53 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 07C6427E5068FF4C00B4ACE7 /* G7ShareFollowersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BD28E35C047C73E1EC8AE73 /* G7ShareFollowersView.swift */; }; 0BE316E9E980C04C39842D94 /* G7SensorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A301AE5854C83E5BD9C6539 /* G7SensorModel.swift */; }; 0C557E84497DE72C92503098 /* G7AlertsFromLoopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2A62D050A4DCCED5F4A51F9 /* G7AlertsFromLoopView.swift */; }; 0F2F5FD6CB203E12707A95F0 /* G7SensorPackageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AE2890ABEAE0FB78B276F1 /* G7SensorPackageTests.swift */; }; @@ -182,7 +181,6 @@ 4A3F076AE2157B22DE43B91A /* G7PreviousSensorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PreviousSensorView.swift; sourceTree = ""; }; 4CAA6FF14E0E9C16A25FD3B6 /* G7SessionMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SessionMode.swift; sourceTree = ""; }; 588CB2A449236A6A47F4579B /* G7SessionModeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SessionModeMigrationTests.swift; sourceTree = ""; }; - 5BD28E35C047C73E1EC8AE73 /* G7ShareFollowersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7ShareFollowersView.swift; sourceTree = ""; }; 6180916611D0990A46355025 /* G7PeripheralManager+Handshake.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G7PeripheralManager+Handshake.swift"; sourceTree = ""; }; 63E50F6710E2494A0E14A6D8 /* G7JPAKE.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7JPAKE.swift; sourceTree = ""; }; 64134474341A32F8DFDA266F /* G7P256Tests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7P256Tests.swift; sourceTree = ""; }; @@ -348,7 +346,6 @@ A83C615FDD5582DDDBD42526 /* Share */ = { isa = PBXGroup; children = ( - 5BD28E35C047C73E1EC8AE73 /* G7ShareFollowersView.swift */, 01B1A199C4CD5420A75C58E4 /* G7ShareSignInView.swift */, ); name = Share; @@ -901,7 +898,6 @@ 0C557E84497DE72C92503098 /* G7AlertsFromLoopView.swift in Sources */, EF41639FF51B727324A04A49 /* G7NotificationPermissionsView.swift in Sources */, 86DE2F2A3F8F5B73AFED5E75 /* G7CalibrationFlowView.swift in Sources */, - 07C6427E5068FF4C00B4ACE7 /* G7ShareFollowersView.swift in Sources */, 33D21C38F39F73BCDFCFC0C5 /* G7ShareSignInView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index 274246d..769051e 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -816,17 +816,6 @@ extension G7CGMManager: G7SensorDelegate { ) } - /// A client for the signed-in account, for managing followers. - public var shareClient: G7ShareClient? { - G7ShareCredentialStore().load().map { credentials in - let client = G7ShareClient(credentials: credentials) - client.logHandler = { [weak self] message in - self?.logDeviceCommunication("Dexcom Share: " + message, type: .connection) - } - return client - } - } - /// Verifies the credentials with the service, keeps them, and starts /// uploading. Throws the service's refusal if they are wrong. public func signInToShare(_ credentials: G7ShareCredentials) async throws { diff --git a/G7SensorKit/Share/G7ShareClient.swift b/G7SensorKit/Share/G7ShareClient.swift index 67c326e..76b9252 100644 --- a/G7SensorKit/Share/G7ShareClient.swift +++ b/G7SensorKit/Share/G7ShareClient.swift @@ -39,21 +39,11 @@ public enum G7ShareError: Error, Equatable { public static let receiverSerialMismatch = "MonitoredReceiverSerialNumberDoesNotMatch" public static let monitoringSessionNotActive = "MonitoringSessionNotActive" public static let duplicateEgvPosted = "DuplicateEgvPosted" - public static let contactNameTaken = "ContactNameAlreadyExists" - /// CreateSubscriptionInvitation's refusal while a new contact has not - /// reached it yet ("Failed to read Contact by id"). - public var isContactNotFound: Bool { - serviceCode?.localizedCaseInsensitiveContains("ContactIdNotFound") == true - } - - /// CreateContact's refusal when the account already has a contact of - /// that name ("The contact name already exists for this account"). - public var isContactNameTaken: Bool { - guard case .service(let code, let message) = self else { return false } - return code.localizedCaseInsensitiveContains("ContactNameAlreadyExists") - || code.localizedCaseInsensitiveContains("ContactAlreadyExists") - || (message ?? "").localizedCaseInsensitiveContains("contact name already exists") + /// The account's monitored device is not this serial, e.g. after a + /// sensor change. + public var isReceiverRefused: Bool { + serviceCode == G7ShareError.receiverNotAssigned || serviceCode == G7ShareError.receiverSerialMismatch } /// Starting a monitoring session when one is already running is refused @@ -139,41 +129,6 @@ public struct G7ShareReading: Equatable { } } -/// A follower, as the service lists it. -public struct G7ShareFollower: Equatable, Identifiable { - public let contactId: String - public let subscriptionId: String? - public let contactName: String - public let displayName: String? - public let state: String? - public let permissions: Int? - - public var id: String { contactId } -} - -/// The alert settings a follower starts with; the follower can change them -/// in the Follow app. Values are mg/dL and ISO 8601 durations. -public struct G7ShareFollowerAlerts: Equatable { - public var highEnabled = false - public var highThreshold = 200 - public var lowEnabled = false - public var lowThreshold = 70 - public var urgentLowEnabled = true - public var urgentLowThreshold = 55 - public var noDataEnabled = false - - public init() {} - - var json: [String: Any] { - [ - "HighAlert": ["MinValue": highThreshold, "MaxValue": 401, "AlarmDelay": "PT1H", "RealarmDelay": "PT2H", "AlertType": 1, "IsEnabled": highEnabled, "Sound": "High.wav"], - "LowAlert": ["MinValue": 39, "MaxValue": lowThreshold, "AlarmDelay": "PT30M", "RealarmDelay": "PT2H", "AlertType": 2, "IsEnabled": lowEnabled, "Sound": "Low.wav"], - "FixedLowAlert": ["MinValue": 39, "MaxValue": urgentLowThreshold, "AlarmDelay": "PT0M", "RealarmDelay": "PT30M", "AlertType": 3, "IsEnabled": urgentLowEnabled, "Sound": "UrgentLow.wav"], - "NoDataAlert": ["MinValue": 39, "MaxValue": 401, "AlarmDelay": "PT1H", "RealarmDelay": "PT0M", "AlertType": 4, "IsEnabled": noDataEnabled, "Sound": "NoData.wav"], - ] - } -} - /// Talks to the Share service for one account. Signs in on demand and /// keeps the session id; `sessionId` is nil until the first call. public final class G7ShareClient { @@ -231,23 +186,13 @@ public final class G7ShareClient { // MARK: - Uploading - /// Whether the service has this serial as the account's monitored - /// device: "AssignedToYou", "NotAssigned", or assigned elsewhere. - public func receiverAssignment(serial: String) async throws -> String { - let sessionId = try await requireSession() - return try await post("Publisher/CheckMonitoredReceiverAssignmentStatus", query: ["sessionId": sessionId, "serialNumber": serial]) - } - + /// Makes this serial the account's monitored device, replacing whatever + /// was registered before. public func assignReceiver(serial: String) async throws { let sessionId = try await requireSession() try await postExpectingNothing("Publisher/ReplacePublisherAccountMonitoredReceiver", query: ["sessionId": sessionId, "serialNumber": serial]) } - public func isRemoteMonitoringSessionActive() async throws -> Bool { - let sessionId = try await requireSession() - return try await post("Publisher/IsRemoteMonitoringSessionActive", query: ["sessionId": sessionId]) - } - public func startRemoteMonitoringSession(serial: String) async throws { let sessionId = try await requireSession() try await postExpectingNothing("Publisher/StartRemoteMonitoringSession", query: ["sessionId": sessionId, "serialNumber": serial]) @@ -264,94 +209,6 @@ public final class G7ShareClient { ["SN": serial, "Egvs": readings.map(\.json), "TA": -5] } - // MARK: - Followers - - public func listFollowers() async throws -> [G7ShareFollower] { - let sessionId = try await requireSession() - let entries: [[String: Any]] = try await post("Publisher/ListPublisherAccountSubscriptions", query: ["sessionId": sessionId]) - return entries.compactMap { entry in - guard let contactId = entry["ContactId"] as? String else { return nil } - return G7ShareFollower( - contactId: contactId, - subscriptionId: entry["SubscriptionId"] as? String, - contactName: entry["ContactName"] as? String ?? "", - displayName: entry["DisplayName"] as? String, - state: (entry["State"] as? String) ?? (entry["State"] as? Int).map(String.init), - permissions: entry["Permissions"] as? Int - ) - } - } - - /// Invites someone to follow: a contact is created for them and an - /// invitation attached to it, which the service emails. Returns the - /// contact id, which is also what removes them. - public func inviteFollower(name: String, email: String, displayName: String, alerts: G7ShareFollowerAlerts = G7ShareFollowerAlerts()) async throws -> String { - let sessionId = try await requireSession() - let contactId: String - var createdContact = false - do { - contactId = try await post("Publisher/CreateContact", query: ["sessionId": sessionId, "contactName": name, "emailAddress": email]) - createdContact = true - } catch let error as G7ShareError where error.isContactNameTaken { - // A contact of that name is already on the account, e.g. from an - // invitation that did not complete. Reuse it. - guard let existing = try await existingContactId(named: name, sessionId: sessionId) else { - throw G7ShareError.service(code: G7ShareError.contactNameTaken, message: LocalizedString("A contact with this name was left on the account by an earlier invitation that did not complete, and it cannot be reused. Invite them under a slightly different name.", comment: "Share error: contact name taken by an orphaned contact")) - } - logHandler?("Reusing existing contact \(existing) for \(name)") - contactId = existing - } - - let invitation: [String: Any] = [ - "AlertSettings": alerts.json, - "Permissions": 1, - "DisplayName": displayName, - ] - // A contact that was just created takes the invitation side of the - // service a while to see (DeleteContact knows it at once; observed - // 2 s not always being enough), so keep trying with a growing wait. - // If it never appears, take the contact back out so the name is free - // for another attempt. - var attempt = 0 - while true { - do { - try await createInvitation(contactId: contactId, sessionId: sessionId, body: invitation) - return contactId - } catch let error as G7ShareError where error.isContactNotFound && attempt < G7ShareClient.invitationRetryDelays.count { - let delay = G7ShareClient.invitationRetryDelays[attempt] - attempt += 1 - logHandler?("Contact not visible to the invitation yet; retrying in \(Int(delay)) s") - try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) - } catch { - if createdContact { - logHandler?("Invitation failed; removing the contact just created") - try? await removeFollower(contactId: contactId) - } - throw error - } - } - } - - /// The id of an existing contact, by name, among the followers. A - /// contact with no subscription cannot be found: `ReadContactByName` - /// belongs to the newer request generation and wants a signed request - /// ("String parameter 'signedRequest' cannot be null"), signed with a - /// key the Dexcom app holds. - private func existingContactId(named name: String, sessionId: String) async throws -> String? { - try await listFollowers().first(where: { $0.contactName.caseInsensitiveCompare(name) == .orderedSame })?.contactId - } - - static let invitationRetryDelays: [TimeInterval] = [2, 4, 8] - - private func createInvitation(contactId: String, sessionId: String, body: [String: Any]) async throws { - let _: String = try await post("Publisher/CreateSubscriptionInvitation", query: ["sessionId": sessionId, "contactId": contactId], body: body) - } - - public func removeFollower(contactId: String) async throws { - let sessionId = try await requireSession() - try await postExpectingNothing("Publisher/DeleteContact", query: ["sessionId": sessionId, "contactId": contactId]) - } - // MARK: - Transport private func request(_ path: String, query: [String: String], body: [String: Any]?) throws -> URLRequest { diff --git a/G7SensorKit/Share/G7ShareUploader.swift b/G7SensorKit/Share/G7ShareUploader.swift index 3b8daf1..6087c27 100644 --- a/G7SensorKit/Share/G7ShareUploader.swift +++ b/G7SensorKit/Share/G7ShareUploader.swift @@ -23,11 +23,10 @@ public struct G7ShareUploadStatus: Equatable { } } -/// Sends readings to Dexcom Share as they arrive. The service wants a -/// signed-in session with the sensor registered as the account's device and -/// a monitoring session started; all of that is done lazily and redone when -/// the service says it has lapsed. Readings that could not be sent wait for -/// the next attempt, up to a day's worth. +/// Sends readings to Dexcom Share as they arrive. It posts first and only +/// signs in, starts a monitoring session or registers the sensor as the +/// account's device when the service refuses for that reason. Readings +/// that could not be sent wait for the next attempt, up to a day's worth. final class G7ShareUploader { private let log = OSLog(category: "G7ShareUploader") private let queue = DispatchQueue(label: "org.loopkit.G7SensorKit.share") @@ -35,8 +34,9 @@ final class G7ShareUploader { private let client: G7ShareClient private var pending: [G7ShareReading] = [] private var uploadedThrough: Date? - private var receiverReady = false - private var monitoringReady = false + /// The serial last registered as the account's device, so a refusal for + /// it is not answered by registering it again. + private var assignedSerial: String? private var task: Task? private(set) var status = G7ShareUploadStatus() @@ -115,47 +115,65 @@ final class G7ShareUploader { } } + private enum Recovery { + case signIn + case startMonitoring + case assignReceiver + } + private func upload(_ batch: [G7ShareReading], serial: String) async -> Result { - do { - try await uploadOnce(batch, serial: serial) - return .success(()) - } catch let error as G7ShareError where error.isSessionExpired || error.serviceCode == G7ShareError.monitoringSessionNotActive { - // A lapsed session or monitoring window: start over once. - client.forgetSession() - receiverReady = false - monitoringReady = false + var attempted: Set = [] + while true { do { - try await uploadOnce(batch, serial: serial) + try await post(batch, serial: serial) return .success(()) + } catch let error as G7ShareError { + guard let recovery = recovery(for: error, serial: serial), attempted.insert(recovery).inserted else { + return .failure(error) + } + do { + try await perform(recovery, serial: serial) + } catch { + return .failure(error) + } } catch { return .failure(error) } - } catch { - return .failure(error) } } - private func uploadOnce(_ batch: [G7ShareReading], serial: String) async throws { - if !receiverReady { - let assignment = try await client.receiverAssignment(serial: serial) - if assignment != "AssignedToYou" { - onLog?("Registering sensor \(serial) as the Dexcom Share device (was \(assignment))") - try await client.assignReceiver(serial: serial) - } - receiverReady = true + private func recovery(for error: G7ShareError, serial: String) -> Recovery? { + if error.isSessionExpired { + return .signIn } - if !monitoringReady { - if try await client.isRemoteMonitoringSessionActive() { - onLog?("Dexcom Share monitoring session is already active") - } else { - do { - try await client.startRemoteMonitoringSession(serial: serial) - } catch let error as G7ShareError where error.isMonitoringSessionAlreadyActive { - // Started by someone else in the meantime; fine. - } + if error.serviceCode == G7ShareError.monitoringSessionNotActive { + return .startMonitoring + } + if error.isReceiverRefused, assignedSerial != serial { + return .assignReceiver + } + return nil + } + + private func perform(_ recovery: Recovery, serial: String) async throws { + switch recovery { + case .signIn: + client.forgetSession() + try await client.signIn() + case .startMonitoring: + do { + try await client.startRemoteMonitoringSession(serial: serial) + } catch let error as G7ShareError where error.isMonitoringSessionAlreadyActive { + // Started by someone else in the meantime; fine. } - monitoringReady = true + case .assignReceiver: + onLog?("Registering sensor \(serial) as the Dexcom Share device") + try await client.assignReceiver(serial: serial) + assignedSerial = serial } + } + + private func post(_ batch: [G7ShareReading], serial: String) async throws { do { try await client.postReadings(batch, serial: serial) } catch let error as G7ShareError where error.serviceCode == G7ShareError.duplicateEgvPosted { diff --git a/G7SensorKitTests/G7ShareTests.swift b/G7SensorKitTests/G7ShareTests.swift index ca209dd..336228e 100644 --- a/G7SensorKitTests/G7ShareTests.swift +++ b/G7SensorKitTests/G7ShareTests.swift @@ -30,18 +30,6 @@ final class G7ShareTests: XCTestCase { XCTAssertTrue(JSONSerialization.isValidJSONObject(payload)) } - func testFollowerAlertsPayload() { - var alerts = G7ShareFollowerAlerts() - alerts.lowEnabled = true - alerts.lowThreshold = 80 - let json = alerts.json - let low = json["LowAlert"] as? [String: Any] - XCTAssertEqual(low?["IsEnabled"] as? Bool, true) - XCTAssertEqual(low?["MaxValue"] as? Int, 80) - XCTAssertEqual((json["FixedLowAlert"] as? [String: Any])?["IsEnabled"] as? Bool, true) - XCTAssertTrue(JSONSerialization.isValidJSONObject(json)) - } - func testServiceErrorClassification() { XCTAssertTrue(G7ShareError.service(code: "SessionNotValid", message: nil).isSessionExpired) XCTAssertTrue(G7ShareError.service(code: "SessionIdNotFound", message: nil).isSessionExpired) @@ -51,9 +39,9 @@ final class G7ShareTests: XCTestCase { XCTAssertTrue(G7ShareError.service(code: "MonitoringSessionAlreadyActive", message: nil).isMonitoringSessionAlreadyActive) XCTAssertTrue(G7ShareError.service(code: "Unknown", message: "Publisher account already has an active monitoring session.").isMonitoringSessionAlreadyActive) XCTAssertFalse(G7ShareError.service(code: "MonitoringSessionNotActive", message: nil).isMonitoringSessionAlreadyActive) - XCTAssertTrue(G7ShareError.service(code: "Unknown", message: "The contact name already exists for this account.").isContactNameTaken) - XCTAssertTrue(G7ShareError.service(code: "ContactNameAlreadyExists", message: nil).isContactNameTaken) - XCTAssertTrue(G7ShareError.service(code: "ContactIdNotFound", message: "Failed to read Contact by id.").isContactNotFound) + XCTAssertTrue(G7ShareError.service(code: "MonitoredReceiverNotAssigned", message: nil).isReceiverRefused) + XCTAssertTrue(G7ShareError.service(code: "MonitoredReceiverSerialNumberDoesNotMatch", message: nil).isReceiverRefused) + XCTAssertFalse(G7ShareError.service(code: "SessionNotValid", message: nil).isReceiverRefused) } func testServers() { @@ -76,4 +64,125 @@ final class G7ShareTests: XCTestCase { XCTAssertEqual(restored.shareLastError, "SessionNotValid") XCTAssertTrue(PropertyListSerialization.propertyList(state.rawValue, isValidFor: .binary)) } + + // MARK: - Upload flow + + override func tearDown() { + G7ShareStubProtocol.responses = [] + G7ShareStubProtocol.requestedPaths = [] + super.tearDown() + } + + func testUploadPostsWithoutRegisteringFirst() { + G7ShareStubProtocol.responses = [.string("account"), .string("session"), .empty] + let status = runUpload() + XCTAssertNotNil(status.lastUploadAt) + XCTAssertEqual(G7ShareStubProtocol.requestedPaths, [ + "General/AuthenticatePublisherAccount", + "General/LoginPublisherAccountById", + "Publisher/PostReceiverEgvRecords", + ]) + } + + func testUploadRegistersSensorAndStartsMonitoringOnlyWhenRefused() { + G7ShareStubProtocol.responses = [ + .string("account"), .string("session"), + .refusal("MonitoredReceiverNotAssigned"), .empty, + .refusal("MonitoringSessionNotActive"), .empty, + .empty, + ] + let status = runUpload() + XCTAssertNotNil(status.lastUploadAt) + XCTAssertEqual(G7ShareStubProtocol.requestedPaths, [ + "General/AuthenticatePublisherAccount", + "General/LoginPublisherAccountById", + "Publisher/PostReceiverEgvRecords", + "Publisher/ReplacePublisherAccountMonitoredReceiver", + "Publisher/PostReceiverEgvRecords", + "Publisher/StartRemoteMonitoringSession", + "Publisher/PostReceiverEgvRecords", + ]) + } + + func testUploadRegistersSensorOnlyOnce() { + G7ShareStubProtocol.responses = [ + .string("account"), .string("session"), + .refusal("MonitoredReceiverNotAssigned"), .empty, + .refusal("MonitoredReceiverNotAssigned"), + ] + let status = runUpload() + XCTAssertNil(status.lastUploadAt) + XCTAssertNotNil(status.lastError) + XCTAssertEqual(G7ShareStubProtocol.requestedPaths.filter { $0 == "Publisher/ReplacePublisherAccountMonitoredReceiver" }.count, 1) + } + + func testUploadSignsInAgainWhenSessionLapses() { + G7ShareStubProtocol.responses = [ + .string("account"), .string("session"), + .refusal("SessionNotValid"), + .string("account"), .string("session2"), + .empty, + ] + let status = runUpload() + XCTAssertNotNil(status.lastUploadAt) + XCTAssertEqual(G7ShareStubProtocol.requestedPaths.filter { $0 == "Publisher/PostReceiverEgvRecords" }.count, 2) + } + + private func runUpload() -> G7ShareUploadStatus { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [G7ShareStubProtocol.self] + let client = G7ShareClient(credentials: G7ShareCredentials(username: "user", password: "pass", server: .us), session: URLSession(configuration: configuration)) + let uploader = G7ShareUploader(client: client, uploadedThrough: nil) + uploader.serial = "123456789012" + let finished = expectation(description: "upload finished") + var result = G7ShareUploadStatus() + uploader.onStatusChange = { status, _ in + guard status.lastUploadAt != nil || status.lastError != nil else { return } + result = status + finished.fulfill() + } + uploader.enqueue([G7ShareReading(date: Date(), glucose: 120, trend: .flat)]) + wait(for: [finished], timeout: 5) + return result + } +} + +/// Answers Share requests from a script, in order, and records their paths. +final class G7ShareStubProtocol: URLProtocol { + enum Response { + case string(String) + case empty + case refusal(String) + } + + static var responses: [Response] = [] + static var requestedPaths: [String] = [] + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let components = request.url?.pathComponents ?? [] + G7ShareStubProtocol.requestedPaths.append(components.suffix(2).joined(separator: "/")) + let next = G7ShareStubProtocol.responses.isEmpty ? .refusal("UnexpectedRequest") : G7ShareStubProtocol.responses.removeFirst() + let status: Int + let body: Data + switch next { + case .string(let value): + status = 200 + body = try! JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) + case .empty: + status = 200 + body = Data() + case .refusal(let code): + status = 500 + body = try! JSONSerialization.data(withJSONObject: ["Code": code, "Message": ""]) + } + let response = HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: body) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} } diff --git a/G7SensorKitUI/Views/G7SettingsView.swift b/G7SensorKitUI/Views/G7SettingsView.swift index f46cc10..a25db89 100644 --- a/G7SensorKitUI/Views/G7SettingsView.swift +++ b/G7SensorKitUI/Views/G7SettingsView.swift @@ -462,11 +462,6 @@ if viewModel.sessionMode == .eavesdropping { .font(.footnote) .foregroundColor(.secondary) } - if let client = viewModel.shareClient { - NavigationLink(destination: G7ShareFollowersView(client: client)) { - Text(LocalizedString("Followers", comment: "Row label opening the followers page")) - } - } Button(action: viewModel.signOutOfShare) { Text(LocalizedString("Sign Out", comment: "Button title to sign out of Dexcom Share")) .foregroundColor(guidanceColors.critical) diff --git a/G7SensorKitUI/Views/G7SettingsViewModel.swift b/G7SensorKitUI/Views/G7SettingsViewModel.swift index 7764d6a..b3c0eca 100644 --- a/G7SensorKitUI/Views/G7SettingsViewModel.swift +++ b/G7SensorKitUI/Views/G7SettingsViewModel.swift @@ -155,10 +155,6 @@ class G7SettingsViewModel: ObservableObject { updateValues() } - var shareClient: G7ShareClient? { - cgmManager.shareClient - } - // MARK: - Calibration /// The last reliable reading in mg/dL, for the calibration entry to diff --git a/G7SensorKitUI/Views/Share/G7ShareFollowersView.swift b/G7SensorKitUI/Views/Share/G7ShareFollowersView.swift deleted file mode 100644 index c6749de..0000000 --- a/G7SensorKitUI/Views/Share/G7ShareFollowersView.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// G7ShareFollowersView.swift -// G7SensorKitUI -// -// Copyright © 2026 LoopKit Authors. All rights reserved. -// - -import G7SensorKit -import LoopKitUI -import SwiftUI - -/// The account's followers, with invitations and removal, through the -/// same service the Dexcom app uses. An invited follower gets an email from -/// Dexcom with instructions to accept in the Follow app. -struct G7ShareFollowersView: View { - let client: G7ShareClient - - @Environment(\.guidanceColors) private var guidanceColors - - @State private var followers: [G7ShareFollower] = [] - @State private var isLoading = true - @State private var errorMessage: String? - @State private var showingInvite = false - - var body: some View { - List { - if let errorMessage = errorMessage { - Section { - Label(errorMessage, systemImage: "exclamationmark.triangle.fill") - .foregroundColor(guidanceColors.critical) - } - } - Section { - if isLoading { - HStack { Spacer(); ProgressView(); Spacer() } - } else if followers.isEmpty { - Text(LocalizedString("No followers yet.", comment: "Followers list: empty")) - .foregroundColor(.secondary) - } else { - ForEach(followers) { follower in - VStack(alignment: .leading, spacing: 2) { - Text(follower.displayName.flatMap { $0.isEmpty ? nil : $0 } ?? follower.contactName) - HStack { - Text(follower.contactName) - if let state = follower.state { - Text("· " + stateDescription(state)) - } - } - .font(.footnote) - .foregroundColor(.secondary) - } - } - .onDelete(perform: remove) - } - } header: { - Text(LocalizedString("Followers", comment: "Section header for the followers list")) - } footer: { - Text(LocalizedString("A new follower receives an email from Dexcom with instructions to sign up for the Dexcom Follow app and accept the invitation. Swipe to remove a follower.", comment: "Followers list footer")) - } - } - .insetGroupedListStyle() - .navigationBarTitle(Text(LocalizedString("Followers", comment: "Navigation title of the followers page")), displayMode: .inline) - .navigationBarItems(trailing: Button(action: { showingInvite = true }) { - Image(systemName: "person.badge.plus") - }) - .sheet(isPresented: $showingInvite) { - NavigationView { - G7ShareInviteFollowerView(client: client) { - showingInvite = false - reload() - } - } - } - .onAppear(perform: reload) - } - - private func stateDescription(_ state: String) -> String { - switch state.lowercased() { - case "active", "2": return LocalizedString("following", comment: "Follower state: active") - case "invited", "1", "pending": return LocalizedString("invited", comment: "Follower state: invitation pending") - default: return state - } - } - - private func reload() { - isLoading = true - Task { - do { - let list = try await client.listFollowers() - await MainActor.run { - followers = list - errorMessage = nil - isLoading = false - } - } catch { - await MainActor.run { - errorMessage = error.localizedDescription - isLoading = false - } - } - } - } - - private func remove(at offsets: IndexSet) { - let removed = offsets.map { followers[$0] } - followers.remove(atOffsets: offsets) - Task { - for follower in removed { - do { - try await client.removeFollower(contactId: follower.contactId) - } catch { - await MainActor.run { errorMessage = error.localizedDescription } - } - } - await MainActor.run { reload() } - } - } -} - -/// Who to invite and the alerts they start with. Dexcom emails the rest. -struct G7ShareInviteFollowerView: View { - let client: G7ShareClient - var didInvite: () -> Void - - @Environment(\.dismiss) private var dismiss - @Environment(\.guidanceColors) private var guidanceColors - - @State private var name = "" - @State private var displayName = "" - @State private var email = "" - @State private var alerts = G7ShareFollowerAlerts() - @State private var isSending = false - @State private var errorMessage: String? - - private var canSubmit: Bool { - !name.trimmingCharacters(in: .whitespaces).isEmpty && email.contains("@") && !isSending - } - - var body: some View { - List { - Section { - TextField(LocalizedString("Follower's name", comment: "Invite follower field placeholder"), text: $name) - .textContentType(.name) - TextField(LocalizedString("Email address", comment: "Invite follower field placeholder"), text: $email) - .textContentType(.emailAddress) - .keyboardType(.emailAddress) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField(LocalizedString("Your name, as shown to them", comment: "Invite follower field placeholder for the publisher's display name"), text: $displayName) - .textContentType(.nickname) - } footer: { - Text(LocalizedString("Dexcom emails the invitation. The follower accepts it in the Dexcom Follow app.", comment: "Invite follower footer")) - } - - Section(header: Text(LocalizedString("Starting Alerts", comment: "Section header for a new follower's initial alert settings"))) { - Toggle(isOn: $alerts.urgentLowEnabled) { - thresholdLabel(LocalizedString("Urgent Low", comment: "Follower alert: urgent low"), alerts.urgentLowThreshold) - } - Stepper(value: $alerts.urgentLowThreshold, in: 40...80, step: 5) { EmptyView() }.labelsHidden().disabled(!alerts.urgentLowEnabled) - Toggle(isOn: $alerts.lowEnabled) { - thresholdLabel(LocalizedString("Low", comment: "Follower alert: low"), alerts.lowThreshold) - } - Stepper(value: $alerts.lowThreshold, in: 60...150, step: 5) { EmptyView() }.labelsHidden().disabled(!alerts.lowEnabled) - Toggle(isOn: $alerts.highEnabled) { - thresholdLabel(LocalizedString("High", comment: "Follower alert: high"), alerts.highThreshold) - } - Stepper(value: $alerts.highThreshold, in: 120...400, step: 10) { EmptyView() }.labelsHidden().disabled(!alerts.highEnabled) - Toggle(LocalizedString("No Data (1 hour)", comment: "Follower alert: no data"), isOn: $alerts.noDataEnabled) - } - - } - .insetGroupedListStyle() - .alert( - LocalizedString("Invitation Failed", comment: "Title of the alert when a follower invitation is refused"), - isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } }) - ) { - Button(LocalizedString("OK", comment: "Alert acknowledgment button label"), role: .cancel) {} - } message: { - Text(errorMessage ?? "") - } - .navigationBarTitle(Text(LocalizedString("Invite Follower", comment: "Navigation title of the invite follower page")), displayMode: .inline) - .navigationBarItems( - leading: Button(LocalizedString("Cancel", comment: "Button text to cancel G7 setup")) { dismiss() }, - trailing: Button(action: submit) { - if isSending { ProgressView() } else { Text(LocalizedString("Invite", comment: "Button title to send a follower invitation")) } - }.disabled(!canSubmit) - ) - } - - private func thresholdLabel(_ title: String, _ mgdl: Int) -> some View { - HStack { - Text(title) - Spacer() - Text("\(mgdl) mg/dL").foregroundColor(.secondary) - } - } - - private func submit() { - isSending = true - errorMessage = nil - let shownName = displayName.trimmingCharacters(in: .whitespaces) - Task { - do { - _ = try await client.inviteFollower( - name: name.trimmingCharacters(in: .whitespaces), - email: email.trimmingCharacters(in: .whitespaces), - displayName: shownName.isEmpty ? name.trimmingCharacters(in: .whitespaces) : shownName, - alerts: alerts - ) - await MainActor.run { - isSending = false - didInvite() - } - } catch { - await MainActor.run { - isSending = false - errorMessage = error.localizedDescription - } - } - } - } -} From da3477d3e3e06b5552a74b5519a73b21fb7a5350 Mon Sep 17 00:00:00 2001 From: Deniz Cengiz <48965855+dnzxy@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:40:12 +0200 Subject: [PATCH 2/2] Recover from a refused Share recovery step A monitoring session refused because the sensor is not registered yet failed the upload, so a new sensor or account never got registered and every retry repeated the same refusal. A recovery's own refusal now goes through the same recovery, and a new registration may start a new monitoring session. Keep a registered serial until relaunch or sign-in on purpose: if the Dexcom app takes the account's device back, let it. --- G7SensorKit/Share/G7ShareUploader.swift | 23 +++++++--- G7SensorKitTests/G7ShareTests.swift | 60 ++++++++++++++++++++++--- 2 files changed, 71 insertions(+), 12 deletions(-) diff --git a/G7SensorKit/Share/G7ShareUploader.swift b/G7SensorKit/Share/G7ShareUploader.swift index 6087c27..2c28380 100644 --- a/G7SensorKit/Share/G7ShareUploader.swift +++ b/G7SensorKit/Share/G7ShareUploader.swift @@ -35,7 +35,9 @@ final class G7ShareUploader { private var pending: [G7ShareReading] = [] private var uploadedThrough: Date? /// The serial last registered as the account's device, so a refusal for - /// it is not answered by registering it again. + /// it is not answered by registering it again. Kept until relaunch or a + /// new sign-in: if the Dexcom app takes the account's device back, we + /// let it rather than taking turns with it. private var assignedSerial: String? private var task: Task? private(set) var status = G7ShareUploadStatus() @@ -121,21 +123,30 @@ final class G7ShareUploader { case assignReceiver } + /// Posts, answering each refusal with its recovery, including a refusal of + /// a recovery itself (a monitoring session refused because the sensor is + /// not registered yet). Each recovery runs once per attempt, except that a + /// new registration may need a new monitoring session. private func upload(_ batch: [G7ShareReading], serial: String) async -> Result { var attempted: Set = [] + var next: Recovery? while true { do { + if let recovery = next { + next = nil + try await perform(recovery, serial: serial) + if recovery == .assignReceiver { + attempted.remove(.startMonitoring) + } + continue + } try await post(batch, serial: serial) return .success(()) } catch let error as G7ShareError { guard let recovery = recovery(for: error, serial: serial), attempted.insert(recovery).inserted else { return .failure(error) } - do { - try await perform(recovery, serial: serial) - } catch { - return .failure(error) - } + next = recovery } catch { return .failure(error) } diff --git a/G7SensorKitTests/G7ShareTests.swift b/G7SensorKitTests/G7ShareTests.swift index 336228e..07a5071 100644 --- a/G7SensorKitTests/G7ShareTests.swift +++ b/G7SensorKitTests/G7ShareTests.swift @@ -116,6 +116,43 @@ final class G7ShareTests: XCTestCase { XCTAssertEqual(G7ShareStubProtocol.requestedPaths.filter { $0 == "Publisher/ReplacePublisherAccountMonitoredReceiver" }.count, 1) } + func testUploadRegistersSensorWhenMonitoringStartIsRefused() { + G7ShareStubProtocol.responses = [ + .string("account"), .string("session"), + .refusal("MonitoringSessionNotActive"), + .refusal("MonitoredReceiverNotAssigned"), .empty, + .refusal("MonitoringSessionNotActive"), .empty, + .empty, + ] + let status = runUpload() + XCTAssertNotNil(status.lastUploadAt) + XCTAssertEqual(G7ShareStubProtocol.requestedPaths, [ + "General/AuthenticatePublisherAccount", + "General/LoginPublisherAccountById", + "Publisher/PostReceiverEgvRecords", + "Publisher/StartRemoteMonitoringSession", + "Publisher/ReplacePublisherAccountMonitoredReceiver", + "Publisher/PostReceiverEgvRecords", + "Publisher/StartRemoteMonitoringSession", + "Publisher/PostReceiverEgvRecords", + ]) + } + + func testUploadDoesNotTakeTheDeviceBackOnceRegistered() { + let uploader = makeUploader() + G7ShareStubProtocol.responses = [ + .string("account"), .string("session"), + .refusal("MonitoredReceiverNotAssigned"), .empty, .empty, + ] + XCTAssertNotNil(runUpload(uploader, readingAt: Date(timeIntervalSinceNow: -300)).lastUploadAt) + + G7ShareStubProtocol.requestedPaths = [] + G7ShareStubProtocol.responses = [.refusal("MonitoredReceiverNotAssigned")] + let status = runUpload(uploader, readingAt: Date(), until: { $0.lastError != nil }) + XCTAssertNotNil(status.lastError) + XCTAssertEqual(G7ShareStubProtocol.requestedPaths, ["Publisher/PostReceiverEgvRecords"]) + } + func testUploadSignsInAgainWhenSessionLapses() { G7ShareStubProtocol.responses = [ .string("account"), .string("session"), @@ -128,21 +165,32 @@ final class G7ShareTests: XCTestCase { XCTAssertEqual(G7ShareStubProtocol.requestedPaths.filter { $0 == "Publisher/PostReceiverEgvRecords" }.count, 2) } - private func runUpload() -> G7ShareUploadStatus { + private func makeUploader() -> G7ShareUploader { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [G7ShareStubProtocol.self] let client = G7ShareClient(credentials: G7ShareCredentials(username: "user", password: "pass", server: .us), session: URLSession(configuration: configuration)) let uploader = G7ShareUploader(client: client, uploadedThrough: nil) uploader.serial = "123456789012" - let finished = expectation(description: "upload finished") + return uploader + } + + private func runUpload( + _ uploader: G7ShareUploader? = nil, + readingAt date: Date = Date(), + until finished: @escaping (G7ShareUploadStatus) -> Bool = { $0.lastUploadAt != nil || $0.lastError != nil } + ) -> G7ShareUploadStatus { + let uploader = uploader ?? makeUploader() + let done = expectation(description: "upload finished") var result = G7ShareUploadStatus() + var reported = false uploader.onStatusChange = { status, _ in - guard status.lastUploadAt != nil || status.lastError != nil else { return } + guard !reported, finished(status) else { return } + reported = true result = status - finished.fulfill() + done.fulfill() } - uploader.enqueue([G7ShareReading(date: Date(), glucose: 120, trend: .flat)]) - wait(for: [finished], timeout: 5) + uploader.enqueue([G7ShareReading(date: date, glucose: 120, trend: .flat)]) + wait(for: [done], timeout: 5) return result } }