Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions G7SensorKit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -237,7 +236,6 @@
4A3F076AE2157B22DE43B91A /* G7PreviousSensorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7PreviousSensorView.swift; sourceTree = "<group>"; };
4CAA6FF14E0E9C16A25FD3B6 /* G7SessionMode.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SessionMode.swift; sourceTree = "<group>"; };
588CB2A449236A6A47F4579B /* G7SessionModeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7SessionModeMigrationTests.swift; sourceTree = "<group>"; };
5BD28E35C047C73E1EC8AE73 /* G7ShareFollowersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7ShareFollowersView.swift; sourceTree = "<group>"; };
6180916611D0990A46355025 /* G7PeripheralManager+Handshake.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "G7PeripheralManager+Handshake.swift"; sourceTree = "<group>"; };
63E50F6710E2494A0E14A6D8 /* G7JPAKE.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7JPAKE.swift; sourceTree = "<group>"; };
64134474341A32F8DFDA266F /* G7P256Tests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = G7P256Tests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -416,7 +414,6 @@
A83C615FDD5582DDDBD42526 /* Share */ = {
isa = PBXGroup;
children = (
5BD28E35C047C73E1EC8AE73 /* G7ShareFollowersView.swift */,
01B1A199C4CD5420A75C58E4 /* G7ShareSignInView.swift */,
);
name = Share;
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 0 additions & 11 deletions G7SensorKit/G7CGMManager/G7CGMManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
155 changes: 6 additions & 149 deletions G7SensorKit/Share/G7ShareClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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])
Expand All @@ -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 {
Expand Down
101 changes: 65 additions & 36 deletions G7SensorKit/Share/G7ShareUploader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,22 @@ 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")

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<Void, Never>?
private(set) var status = G7ShareUploadStatus()

Expand Down Expand Up @@ -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<Void, Error> {
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<Recovery> = []
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 {
Expand Down
Loading