Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 4 additions & 4 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,7 @@
A9DF02CB24F72B9E00B7C988 /* CriticalEventLogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */; };
A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */; };
A9DFAFB524F048A000950D1E /* WatchHistoricalCarbsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */; };
A9E8A80528A7CAC000C0A8A4 /* RemoteCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E8A80428A7CAC000C0A8A4 /* RemoteCommandTests.swift */; };
A9E8A80528A7CAC000C0A8A4 /* RemoteActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9E8A80428A7CAC000C0A8A4 /* RemoteActionTests.swift */; };
A9F5F1F5251050EC00E7C8A4 /* ZipArchiveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */; };
A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */; };
A9F703732489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */; };
Expand Down Expand Up @@ -1407,7 +1407,7 @@
A9DF02CA24F72B9E00B7C988 /* CriticalEventLogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogTests.swift; sourceTree = "<group>"; };
A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbBackfillRequestUserInfoTests.swift; sourceTree = "<group>"; };
A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchHistoricalCarbsTests.swift; sourceTree = "<group>"; };
A9E8A80428A7CAC000C0A8A4 /* RemoteCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteCommandTests.swift; sourceTree = "<group>"; };
A9E8A80428A7CAC000C0A8A4 /* RemoteActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteActionTests.swift; sourceTree = "<group>"; };
A9F5F1F4251050EC00E7C8A4 /* ZipArchiveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchiveTests.swift; sourceTree = "<group>"; };
A9F66FC2247F451500096EA7 /* UIDevice+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Loop.swift"; sourceTree = "<group>"; };
A9F703722489BC8500C98AD8 /* CarbStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CarbStore+SimulatedCoreData.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2803,7 +2803,7 @@
C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */,
A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */,
A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */,
A9E8A80428A7CAC000C0A8A4 /* RemoteCommandTests.swift */,
A9E8A80428A7CAC000C0A8A4 /* RemoteActionTests.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -4121,7 +4121,7 @@
A96DAC2A2838EF8A00D94E38 /* DiagnosticLogTests.swift in Sources */,
A9DAE7D02332D77F006AE942 /* LoopTests.swift in Sources */,
E93E86B024DDE1BD00FF40C8 /* MockGlucoseStore.swift in Sources */,
A9E8A80528A7CAC000C0A8A4 /* RemoteCommandTests.swift in Sources */,
A9E8A80528A7CAC000C0A8A4 /* RemoteActionTests.swift in Sources */,
1DFE9E172447B6270082C280 /* UserNotificationAlertSchedulerTests.swift in Sources */,
B4BC56382518DEA900373647 /* CGMStatusHUDViewModelTests.swift in Sources */,
C1900900252271BB00721625 /* SimpleBolusCalculatorTests.swift in Sources */,
Expand Down
260 changes: 132 additions & 128 deletions Loop/Managers/DeviceDataManager.swift

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions Loop/Managers/NotificationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ extension NotificationManager {

// MARK: - Notifications

@MainActor
static func sendRemoteCommandExpiredNotification(timeExpired: TimeInterval) {
let notification = UNMutableNotificationContent()

Expand Down Expand Up @@ -134,6 +135,7 @@ extension NotificationManager {
UNUserNotificationCenter.current().add(request)
}

@MainActor
static func sendRemoteBolusNotification(amount: Double) {
let notification = UNMutableNotificationContent()
let quantityFormatter = QuantityFormatter()
Expand All @@ -157,6 +159,7 @@ extension NotificationManager {
UNUserNotificationCenter.current().add(request)
}

@MainActor
static func sendRemoteBolusFailureNotification(for error: Error, amount: Double) {
let notification = UNMutableNotificationContent()
let quantityFormatter = QuantityFormatter()
Expand All @@ -178,6 +181,7 @@ extension NotificationManager {
UNUserNotificationCenter.current().add(request)
}

@MainActor
static func sendRemoteCarbEntryNotification(amountInGrams: Double) {
let notification = UNMutableNotificationContent()

Expand All @@ -198,6 +202,7 @@ extension NotificationManager {
UNUserNotificationCenter.current().add(request)
}

@MainActor
static func sendRemoteCarbEntryFailureNotification(for error: Error, amountInGrams: Double) {
let notification = UNMutableNotificationContent()

Expand Down
8 changes: 8 additions & 0 deletions Loop/Managers/RemoteDataServicesManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,14 @@ final class RemoteDataServicesManager {
completion()
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async wrapper


func triggerUpload(for triggeringType: RemoteDataType) async {
return await withCheckedContinuation { continuation in
triggerUpload(for: triggeringType) {
continuation.resume(returning: ())
}
}
}
}

extension RemoteDataServicesManager {
Expand Down
4 changes: 4 additions & 0 deletions Loop/Models/LoopConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ enum LoopConstants {

static let validManualGlucoseEntryRange = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 10)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600)

static let minCarbAbsorptionTime = TimeInterval(minutes: 30)
static let maxCarbAbsorptionTime = TimeInterval(hours: 8)

static let maxCarbEntryPastTime = TimeInterval(hours: (-12))
static let maxCarbEntryFutureTime = TimeInterval(hours: 1)


// MARK - Display settings
Expand Down
74 changes: 14 additions & 60 deletions Loop/Models/RemoteCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,78 +8,34 @@

import Foundation
import LoopKit
import HealthKit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed as each remote action type will own its own errors.

public enum RemoteCommandError: LocalizedError {
case expired
case invalidOTP
case missingMaxBolus
case exceedsMaxBolus
case exceedsMaxCarbs
case invalidCarbs

public var errorDescription: String? {
get {
switch self {
case .expired:
return NSLocalizedString("Expired", comment: "Remote command error description: expired.")
case .invalidOTP:
return NSLocalizedString("Invalid OTP", comment: "Remote command error description: invalid OTP.")
case .missingMaxBolus:
return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Remote command error description: missing maximum bolus in settings.")
case .exceedsMaxBolus:
return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Remote command error description: bolus exceeds maximum bolus in settings.")
case .exceedsMaxCarbs:
return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Remote command error description: carbs exceed maximum amount.")
case .invalidCarbs:
return NSLocalizedString("Invalid carb amount", comment: "Remote command error description: invalid carb amount.")
}
}
}
}


enum RemoteCommand {
case temporaryScheduleOverride(TemporaryScheduleOverride)
case cancelTemporaryOverride
case bolusEntry(Double)
case carbsEntry(NewCarbEntry)
}


// Push Notifications
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally like before, this method parses a notification and extracts a remote action (previously command).

The difference is Loop valid action checks are not done here (ex: Carb amount within reasonable limits). That will instead be managed by the client.

Separating notification payload parsing and validation seems like a good idea here. This will go away when I use Codable to do all this parsing in Remote 2.0.

extension RemoteCommand {
static func createRemoteCommand(notification: [String: Any], allowedPresets: [TemporaryScheduleOverridePreset], defaultAbsorptionTime: TimeInterval, nowDate: Date = Date()) -> Result<RemoteCommand, RemoteCommandParseError> {
extension RemoteAction {
static func createRemoteAction(notification: [String: Any]) -> Result<RemoteAction, RemoteCommandParseError> {
if let overrideName = notification["override-name"] as? String,
let preset = allowedPresets.first(where: { $0.name == overrideName }),
let remoteAddress = notification["remote-address"] as? String
{
var override = preset.createOverride(enactTrigger: .remote(remoteAddress))
var overrideTime: TimeInterval? = nil
if let overrideDurationMinutes = notification["override-duration-minutes"] as? Double {
override.duration = .finite(TimeInterval(minutes: overrideDurationMinutes))
overrideTime = TimeInterval(minutes: overrideDurationMinutes)
}
return .success(.temporaryScheduleOverride(override))
} else if let _ = notification["cancel-temporary-override"] as? String {
return .success(.cancelTemporaryOverride)
return .success(.temporaryScheduleOverride(RemoteOverrideAction(name: overrideName, durationTime: overrideTime, remoteAddress: remoteAddress)))
} else if let _ = notification["cancel-temporary-override"] as? String,
let remoteAddress = notification["remote-address"] as? String
{
return .success(.cancelTemporaryOverride(RemoteOverrideCancelAction(remoteAddress: remoteAddress)))
} else if let bolusValue = notification["bolus-entry"] as? Double {
return .success(.bolusEntry(bolusValue))
return .success(.bolusEntry(RemoteBolusAction(amountInUnits: bolusValue)))
} else if let carbsValue = notification["carbs-entry"] as? Double {

let minAbsorptionTime = TimeInterval(hours: 0.5)
let maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime

var absorptionTime = defaultAbsorptionTime
var absorptionTime: TimeInterval? = nil
if let absorptionOverrideInHours = notification["absorption-time"] as? Double {
absorptionTime = TimeInterval(hours: absorptionOverrideInHours)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Carb food type was missing from the APIs. This will require a change to the Nightscout codebase in order to support this but should be ok when empty too.

if absorptionTime < minAbsorptionTime || absorptionTime > maxAbsorptionTime {
return .failure(RemoteCommandParseError.invalidAbsorptionSeconds(absorptionTime))
}

let quantity = HKQuantity(unit: .gram(), doubleValue: carbsValue)
var foodType = notification["food-type"] as? String ?? nil

var startDate = nowDate
var startDate: Date? = nil
if let notificationStartTimeString = notification["start-time"] as? String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
Expand All @@ -90,16 +46,14 @@ extension RemoteCommand {
}
}

let newEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: "", absorptionTime: absorptionTime)
return .success(.carbsEntry(newEntry))
return .success(.carbsEntry(RemoteCarbAction(amountInGrams: carbsValue, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate)))
} else {
return .failure(RemoteCommandParseError.unhandledNotication("\(notification)"))
}
}

enum RemoteCommandParseError: LocalizedError {
case invalidStartTime(String)
case invalidAbsorptionSeconds(Double)
case unhandledNotication(String)
}
}
108 changes: 108 additions & 0 deletions LoopTests/Models/RemoteActionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I needed to update the RemoteCommandTests with these and change a few that are unnecessary.

I added more tests to LoopKit, which test the individual remote action types themselves which should cover all the cases I know of.

// RemoteActionTests.swift
// LoopTests
//
// Created by Bill Gestrich on 8/13/22.
// Copyright © 2022 LoopKit Authors. All rights reserved.
//

import XCTest
import HealthKit
@testable import Loop
import LoopKit

class RemoteActionTests: XCTestCase {

override func setUpWithError() throws {
}

override func tearDownWithError() throws {
}


//MARK: Carb Entry Command

func testParseCarbEntryNotification_ValidPayload_Succeeds() throws {

//Arrange
let expectedStartDateString = "2022-08-14T03:08:00.000Z"
let expectedCarbsInGrams = 15.0
let expectedDate = dateFormatter().date(from: expectedStartDateString)!
let expectedAbsorptionTimeInHours = 3.0
let expectedFoodType = "🍕"
let otp = 12345
let notification: [String: Any] = [
"carbs-entry":expectedCarbsInGrams,
"absorption-time": expectedAbsorptionTimeInHours,
"food-type": expectedFoodType,
"otp": otp,
"start-time": expectedStartDateString
]

//Act
let action = try RemoteAction.createRemoteAction(notification: notification).get()

//Assert
guard case .carbsEntry(let carbEntry) = action else {
XCTFail("Incorrect case")
return
}
XCTAssertEqual(carbEntry.startDate, expectedDate)
XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours))
XCTAssertEqual(carbEntry.amountInGrams, expectedCarbsInGrams)
XCTAssertEqual(expectedFoodType, carbEntry.foodType)
}

func testParseCarbEntryNotification_MissingCreatedDate_Succeeds() throws {

//Arrange
let expectedCarbsInGrams = 15.0
let expectedAbsorptionTimeInHours = 3.0
let otp = 12345
let notification: [String: Any] = [
"carbs-entry":expectedCarbsInGrams,
"absorption-time": expectedAbsorptionTimeInHours,
"otp": otp
]

//Act
let action = try RemoteAction.createRemoteAction(notification: notification).get()

//Assert
guard case .carbsEntry(let carbEntry) = action else {
XCTFail("Incorrect case")
return
}

XCTAssertEqual(carbEntry.startDate, nil)
XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours))
XCTAssertEqual(carbEntry.amountInGrams, expectedCarbsInGrams)
}

func testParseCarbEntryNotification_InvalidCreatedDate_Fails() throws {

//Arrange
let expectedCarbsInGrams = 15.0
let expectedAbsorptionTimeInHours = 3.0
let otp = 12345
let notification: [String: Any] = [
"carbs-entry": expectedCarbsInGrams,
"absorption-time":expectedAbsorptionTimeInHours,
"otp": otp,
"start-time": "invalid-date-string"
]

//Act + Assert
XCTAssertThrowsError(try RemoteAction.createRemoteAction(notification: notification).get())
}


//MARK: Utils

func dateFormatter() -> ISO8601DateFormatter {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}

}
Loading