diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index 319693f..2b68c83 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ FE87266E3AE98CA87D903665 /* G7PCert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F8C967DDB24EB158A1A45 /* G7PCert.swift */; }; 2BC9F3807D05FB72B54407C6 /* G7Authenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 899E19256E3224A54FA35AD8 /* G7Authenticator.swift */; }; FADE00000000000000000330 /* Locked.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F513B291EB13D00555EB5 /* Locked.swift */; }; - 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 */; }; @@ -237,7 +236,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 = ""; }; @@ -416,7 +414,6 @@ A83C615FDD5582DDDBD42526 /* Share */ = { isa = PBXGroup; children = ( - 5BD28E35C047C73E1EC8AE73 /* G7ShareFollowersView.swift */, 01B1A199C4CD5420A75C58E4 /* G7ShareSignInView.swift */, ); name = Share; @@ -1066,7 +1063,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 153ddfd..64aefc5 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -928,17 +928,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..2c28380 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,11 @@ 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. 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() @@ -115,47 +117,74 @@ final class G7ShareUploader { } } + private enum Recovery { + case signIn + case startMonitoring + 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 { - 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 = [] + var next: Recovery? + while true { do { - try await uploadOnce(batch, serial: serial) + 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) + } + next = recovery } 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..07a5071 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,173 @@ 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 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"), + .refusal("SessionNotValid"), + .string("account"), .string("session2"), + .empty, + ] + let status = runUpload() + XCTAssertNotNil(status.lastUploadAt) + XCTAssertEqual(G7ShareStubProtocol.requestedPaths.filter { $0 == "Publisher/PostReceiverEgvRecords" }.count, 2) + } + + 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" + 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 !reported, finished(status) else { return } + reported = true + result = status + done.fulfill() + } + uploader.enqueue([G7ShareReading(date: date, glucose: 120, trend: .flat)]) + wait(for: [done], 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 4c115f6..10e214e 100644 --- a/G7SensorKitUI/Views/G7SettingsView.swift +++ b/G7SensorKitUI/Views/G7SettingsView.swift @@ -492,11 +492,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 a669725..303637a 100644 --- a/G7SensorKitUI/Views/G7SettingsViewModel.swift +++ b/G7SensorKitUI/Views/G7SettingsViewModel.swift @@ -164,10 +164,6 @@ class G7SettingsViewModel: ObservableObject { updateValues() } - var shareClient: G7ShareClient? { - cgmManager.shareClient - } - // MARK: - Watch direct read /// Offered when a paired watch has the app installed, or while it is on; otherwise the screen is 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 - } - } - } - } -}