Skip to content

Commit 3c8393a

Browse files
codebyminibjorkertmarionbarker
authored
Add remote commands via APNS for Loop users (#434)
* Add remote commands via APNS for Loop users * Use already available overrides from ProfileManager for Loop APNS * sha1 is no longer needed * Removed CommonCrypto since it is not used. * Undo change * Revert APNS-related changes in Config.xcconfig from commit d5ce515 * Fix for loopapns setup not updating and reverting to default value * Fix rounding for bolus confirm button * Centralize jwt-management * Use the sama apnskey, keyid and team storage as trio * Remove stray references to loopAPNS variables * Avoid publishing changes from within view updates for Loop APNS setup * Align buttons with TRC * Fix for crashing camera * cleanup * Cleanup * Add countdown for Loop TOTP code * Mitigate app hang when scanning or adding totp url * Simplified validation * Remove manual device token refresh and move debug info to main settings * Add current totp code to debug / info section * Conditionally enable Nightscout remote type based on if the user is using Trio * Fix for devicetoken and bundleid missing * Removed dead code * Removed dead code * Swapped the incorrect error invalidURL to invalidConfiguration * Refactor OverridePresetsView to use alerts for success/error feedback instead of inline messages * Fix typo * Separate error for jwt token * Merge settings for Loop and Trio * Move guardrails up * Cleanup * Text adjustment * Loop remote setup wll fail if team id is missing * Use entire screen for message when loop apns setup is missing * XCode auto format * Restore accidently removed code * Fix: Preserve APNS production setting when absent from profile on sync * Refactor(UI): Correct navigation structure in RemoteSettingsView to fix header * Fix label in Remote Settings to 'Loop Remote Control' for naming consistency * Add current active override to Loop override screen * Add time picker for carbs timing and fix absorption time with default emojis * Fix multiline TextEditor Done button to dismiss keyboard instead of inserting newline * Extract aps error message * Moved down TRC below LRC * Fix for showing errors correctly * Extract Loop apns error data * Reset to remote control type to none if the looping system is incorrect * Use Random carb entry seconds to avoid NS issues reflecting carbs * Revert accidental change to project.pbxproj * rearrange items on Remote Settings screen * modify import to enable building with Xcode 26 beta 4 * Add extra decimal to insulin input for pumps with more granular dosing * Add better warnings for bolus recommendations --------- Co-authored-by: Jonas Björkert <jonas@bjorkert.se> Co-authored-by: marionbarker <marionbarker@earthlink.net>
1 parent 2442d1b commit 3c8393a

34 files changed

+2743
-575
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,4 @@ fastlane/test_output
8080
fastlane/FastlaneRunner
8181

8282
LoopFollowConfigOverride.xcconfig
83+
.history

LoopFollow.xcodeproj/project.pbxproj

Lines changed: 70 additions & 33 deletions
Large diffs are not rendered by default.

LoopFollow.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 19 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

LoopFollow/Alarm/Alarm.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ struct Alarm: Identifiable, Codable, Equatable {
131131
}
132132

133133
// Mute during calls
134-
if !config.audioDuringCalls && isOnPhoneCall() {
134+
if !config.audioDuringCalls, isOnPhoneCall() {
135135
playSound = false
136136
}
137137

LoopFollow/BackgroundRefresh/BT/BluetoothDevice.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import os
88
import UIKit
99

1010
class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
11-
public weak var bluetoothDeviceDelegate: BluetoothDeviceDelegate?
11+
weak var bluetoothDeviceDelegate: BluetoothDeviceDelegate?
1212
private(set) var deviceAddress: String
1313
private(set) var deviceName: String?
1414
private let CBUUID_Advertisement: String?
@@ -158,7 +158,7 @@ class BluetoothDevice: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate
158158
_ = startScanning()
159159
}
160160

161-
public func cancelConnectionTimer() {
161+
func cancelConnectionTimer() {
162162
if let connectTimeOutTimer = connectTimeOutTimer {
163163
connectTimeOutTimer.invalidate()
164164
self.connectTimeOutTimer = nil

LoopFollow/Controllers/Graphs.swift

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1771,34 +1771,90 @@ extension MainViewController {
17711771
}
17721772
}
17731773

1774+
func extractMessage(from logEntry: String) -> String? {
1775+
// Check if this is a JSON-containing log entry
1776+
guard let jsonStartIndex = logEntry.range(of: "{\"")?.lowerBound else {
1777+
return nil
1778+
}
1779+
1780+
// Extract the error message part (before JSON)
1781+
let errorMessage = String(logEntry[..<jsonStartIndex])
1782+
.trimmingCharacters(in: .whitespacesAndNewlines)
1783+
1784+
// Extract and parse JSON to get context
1785+
let jsonString = String(logEntry[jsonStartIndex...])
1786+
var actionContext = ""
1787+
1788+
if let jsonData = jsonString.data(using: .utf8),
1789+
let json = try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any]
1790+
{
1791+
// Extract relevant action information
1792+
var actionParts: [String] = []
1793+
1794+
// Check for bolus entry
1795+
if let bolusAmount = json["bolus-entry"] as? Double {
1796+
actionParts.append("Bolus: \(bolusAmount) U")
1797+
}
1798+
1799+
// Check for carbs entry
1800+
if let carbsAmount = json["carbs-entry"] as? Double {
1801+
actionParts.append("Carbs: \(carbsAmount) g")
1802+
}
1803+
1804+
// Check for absorption time (relevant for carbs)
1805+
if let absorptionTime = json["absorption-time"] as? Double {
1806+
actionParts.append("Absorption: \(absorptionTime) hrs")
1807+
}
1808+
1809+
// Check for OTP (password)
1810+
if let otp = json["otp"] as? String {
1811+
actionParts.append("OTP: \(otp)")
1812+
}
1813+
1814+
// Check for sender
1815+
if let enteredBy = json["entered-by"] as? String {
1816+
actionParts.append("From: \(enteredBy)")
1817+
}
1818+
1819+
// Combine action parts
1820+
if !actionParts.isEmpty {
1821+
actionContext = " [" + actionParts.joined(separator: ", ") + "]"
1822+
}
1823+
}
1824+
1825+
// Combine error message with action context
1826+
let finalMessage = errorMessage + actionContext
1827+
1828+
return finalMessage.isEmpty ? nil : finalMessage
1829+
}
1830+
17741831
func wrapText(_ text: String, maxLineLength: Int) -> String {
1832+
let messageToWrap = extractMessage(from: text) ?? text
1833+
17751834
guard maxLineLength > 0 else {
1776-
return text
1835+
return messageToWrap
17771836
}
17781837

17791838
var result: [String] = []
1780-
let lines = text.components(separatedBy: .newlines)
1839+
let lines = messageToWrap.components(separatedBy: .newlines)
17811840

17821841
for line in lines {
17831842
var currentLine = ""
17841843
let words = line.components(separatedBy: .whitespaces)
17851844

17861845
for word in words {
1787-
// Handles words that are longer than a single line.
17881846
if word.count > maxLineLength {
17891847
if !currentLine.isEmpty {
17901848
result.append(currentLine)
17911849
currentLine = ""
17921850
}
1793-
17941851
var wordToSplit = word
17951852
while !wordToSplit.isEmpty {
17961853
let splitIndex = wordToSplit.index(wordToSplit.startIndex, offsetBy: min(maxLineLength, wordToSplit.count))
17971854
result.append(String(wordToSplit[..<splitIndex]))
17981855
wordToSplit = String(wordToSplit[splitIndex...])
17991856
}
18001857
} else {
1801-
// The word fits on the line.
18021858
if currentLine.isEmpty {
18031859
currentLine = word
18041860
} else if currentLine.count + word.count + 1 <= maxLineLength {

LoopFollow/Controllers/Nightscout/ProfileManager.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,14 +109,19 @@ final class ProfileManager {
109109
trioOverrides = []
110110
}
111111

112-
Storage.shared.deviceToken.value = profileData.deviceToken ?? ""
112+
Storage.shared.deviceToken.value = profileData.deviceToken ?? profileData.loopSettings?.deviceToken ?? ""
113+
113114
if let expirationDate = profileData.expirationDate {
114115
Storage.shared.expirationDate.value = NightscoutUtils.parseDate(expirationDate)
115116
} else {
116117
Storage.shared.expirationDate.value = nil
117118
}
118-
Storage.shared.bundleId.value = profileData.bundleIdentifier ?? ""
119-
Storage.shared.productionEnvironment.value = profileData.isAPNSProduction ?? false
119+
Storage.shared.bundleId.value = profileData.bundleIdentifier ?? profileData.loopSettings?.bundleIdentifier ?? ""
120+
121+
if let isProduction = profileData.isAPNSProduction {
122+
Storage.shared.productionEnvironment.value = isProduction
123+
}
124+
120125
Storage.shared.teamId.value = profileData.teamID ?? Storage.shared.teamId.value ?? ""
121126
}
122127

LoopFollow/Extensions/HKUnit+Extensions.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ extension HKUnit {
1919
case .millimolesPerLiter:
2020
return 1
2121
case .internationalUnit():
22-
return 2
22+
return 3
2323
default:
2424
return 0
2525
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// LoopFollow
2+
// DateExtensions.swift
3+
// Created by codebymini.
4+
5+
import Foundation
6+
7+
// MARK: - Date Extension for NS Compatibility
8+
9+
extension Date {
10+
/// Creates a new date with the original date's components but current seconds and milliseconds
11+
/// This prevents Nightscout issues with entries at the same exact time
12+
/// - Returns: A new date with randomized milliseconds
13+
func dateUsingCurrentSeconds() -> Date {
14+
let calendar = Calendar.current
15+
16+
// Extracting components from the original date
17+
var components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: self)
18+
19+
// Getting the current seconds and milliseconds
20+
let now = Date()
21+
let nowSeconds = calendar.component(.second, from: now)
22+
let nowMillisecond = calendar.component(.nanosecond, from: now) / 1_000_000
23+
24+
// Setting the seconds and millisecond components
25+
components.second = nowSeconds
26+
components.nanosecond = nowMillisecond * 1_000_000
27+
28+
// Creating a new date with these components
29+
return calendar.date(from: components) ?? self
30+
}
31+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// LoopFollow
2+
// JWTManager.swift
3+
// Created by Jonas Björkert.
4+
5+
import Foundation
6+
import SwiftJWT
7+
8+
struct JWTClaims: Claims {
9+
let iss: String
10+
let iat: Date
11+
}
12+
13+
class JWTManager {
14+
static let shared = JWTManager()
15+
16+
private init() {}
17+
18+
func getOrGenerateJWT(keyId: String, teamId: String, apnsKey: String) -> String? {
19+
// 1. Check for a valid, non-expired JWT directly from Storage.shared
20+
if let jwt = Storage.shared.cachedJWT.value,
21+
let expiration = Storage.shared.jwtExpirationDate.value,
22+
Date() < expiration
23+
{
24+
return jwt
25+
}
26+
27+
// 2. If no valid JWT is found, generate a new one
28+
let header = Header(kid: keyId)
29+
let claims = JWTClaims(iss: teamId, iat: Date())
30+
var jwt = JWT(header: header, claims: claims)
31+
32+
do {
33+
let privateKey = Data(apnsKey.utf8)
34+
let jwtSigner = JWTSigner.es256(privateKey: privateKey)
35+
let signedJWT = try jwt.sign(using: jwtSigner)
36+
37+
// 3. Save the new JWT and its expiration date directly to Storage.shared
38+
Storage.shared.cachedJWT.value = signedJWT
39+
Storage.shared.jwtExpirationDate.value = Date().addingTimeInterval(3600) // Expires in 1 hour
40+
41+
return signedJWT
42+
} catch {
43+
LogManager.shared.log(category: .apns, message: "Failed to sign JWT: \(error.localizedDescription)")
44+
return nil
45+
}
46+
}
47+
48+
// Invalidate the cache by clearing values in Storage.shared
49+
func invalidateCache() {
50+
Storage.shared.cachedJWT.value = nil
51+
Storage.shared.jwtExpirationDate.value = nil
52+
}
53+
}

0 commit comments

Comments
 (0)