From 46a8b4cbd47a2e410877f24b3697f84d64781d0f Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 11:21:54 +0200 Subject: [PATCH 1/2] Block sending loop commands with same totp code --- .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 80 ++++++++++++++++++- .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 80 ++++++++++++++++++- LoopFollow/Storage/Storage.swift | 5 ++ 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index 4377d9ec8..bb69778c7 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -24,6 +24,28 @@ struct LoopAPNSBolusView: View { private let otpPeriod: TimeInterval = 30 private var otpTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + // Computed property to check if TOTP should be blocked + private var isTOTPBlocked: Bool { + guard Storage.shared.loopAPNSTOTPUsed.value else { return false } + + // If we have a timestamp, check if 30 seconds have passed + if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { + let timeSinceLastUsed = Date().timeIntervalSince1970 - lastUsed + if timeSinceLastUsed >= 30 { + // 30 seconds have passed, unblock + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + return false + } + } else { + // No timestamp but flag is set - this shouldn't happen, but let's clean it up + Storage.shared.loopAPNSTOTPUsed.value = false + return false + } + + return true + } + enum AlertType { case success case error @@ -116,9 +138,29 @@ struct LoopAPNSBolusView: View { Text("Send Insulin") } } - .disabled(insulinAmount.doubleValue(for: .internationalUnit()) <= 0 || isLoading) + .disabled(insulinAmount.doubleValue(for: .internationalUnit()) <= 0 || isLoading || isTOTPBlocked) .frame(maxWidth: .infinity) } + + // TOTP Blocking Warning Section + if isTOTPBlocked { + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("TOTP Code Already Used") + .font(.headline) + .foregroundColor(.orange) + } + Text("This TOTP code has already been used for a command. Please wait for the next code to be generated before sending another command.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + .padding(.vertical, 4) + } + } Section(header: Text("Security")) { VStack(alignment: .leading) { Text("Current OTP Code") @@ -159,10 +201,41 @@ struct LoopAPNSBolusView: View { loadRecommendedBolus() // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil + // Don't reset TOTP usage flag here - let the timer handle it + + // Validate TOTP state when view appears + _ = isTOTPBlocked } .onReceive(otpTimer) { _ in let now = Date().timeIntervalSince1970 - otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) + let newOtpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) + + // Check if we've moved to a new TOTP period (when time remaining increases) + if let currentOtpTimeRemaining = otpTimeRemaining, + newOtpTimeRemaining > currentOtpTimeRemaining + { + // New TOTP code generated, reset the usage flag + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + } + + // Also check if we're at the very beginning of a new period (when time remaining is close to 30) + if newOtpTimeRemaining >= 29 { + // We're at the start of a new TOTP period, reset the usage flag + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + } + + // Additional safety check: if we have a timestamp and 30+ seconds have passed, unblock + if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { + let timeSinceLastUsed = now - lastUsed + if timeSinceLastUsed >= 30 { + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + } + } + + otpTimeRemaining = newOtpTimeRemaining // Check if recommended bolus calculation is older than 5 minutes (but less than 12 minutes) if let lastLoopTime = lastLoopTime { @@ -333,6 +406,9 @@ struct LoopAPNSBolusView: View { DispatchQueue.main.async { isLoading = false if success { + // Mark TOTP code as used with timestamp + Storage.shared.loopAPNSTOTPUsed.value = true + Storage.shared.loopAPNSTOTPLastUsed.value = Date().timeIntervalSince1970 alertMessage = "Insulin sent successfully!" alertType = .success LogManager.shared.log( diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index bb1259484..5155fa2ea 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -23,6 +23,28 @@ struct LoopAPNSCarbsView: View { @FocusState private var carbsFieldIsFocused: Bool @FocusState private var absorptionFieldIsFocused: Bool + // Computed property to check if TOTP should be blocked + private var isTOTPBlocked: Bool { + guard Storage.shared.loopAPNSTOTPUsed.value else { return false } + + // If we have a timestamp, check if 30 seconds have passed + if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { + let timeSinceLastUsed = Date().timeIntervalSince1970 - lastUsed + if timeSinceLastUsed >= 30 { + // 30 seconds have passed, unblock + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + return false + } + } else { + // No timestamp but flag is set - this shouldn't happen, but let's clean it up + Storage.shared.loopAPNSTOTPUsed.value = false + return false + } + + return true + } + enum AlertType { case success case error @@ -169,10 +191,30 @@ struct LoopAPNSCarbsView: View { Text("Send Carbs") } } - .disabled(carbsAmount.doubleValue(for: .gram()) <= 0 || isLoading) + .disabled(carbsAmount.doubleValue(for: .gram()) <= 0 || isLoading || isTOTPBlocked) .frame(maxWidth: .infinity) } + // TOTP Blocking Warning Section + if isTOTPBlocked { + Section { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("TOTP Code Already Used") + .font(.headline) + .foregroundColor(.orange) + } + Text("This TOTP code has already been used for a command. Please wait for the next code to be generated before sending another command.") + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + .padding(.vertical, 4) + } + } + Section(header: Text("Security")) { VStack(alignment: .leading) { Text("Current OTP Code") @@ -222,10 +264,41 @@ struct LoopAPNSCarbsView: View { } // Reset timer state so it shows '-' until first tick otpTimeRemaining = nil + // Don't reset TOTP usage flag here - let the timer handle it + + // Validate TOTP state when view appears + _ = isTOTPBlocked } .onReceive(otpTimer) { _ in let now = Date().timeIntervalSince1970 - otpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) + let newOtpTimeRemaining = Int(otpPeriod - (now.truncatingRemainder(dividingBy: otpPeriod))) + + // Check if we've moved to a new TOTP period (when time remaining increases) + if let currentOtpTimeRemaining = otpTimeRemaining, + newOtpTimeRemaining > currentOtpTimeRemaining + { + // New TOTP code generated, reset the usage flag + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + } + + // Also check if we're at the very beginning of a new period (when time remaining is close to 30) + if newOtpTimeRemaining >= 29 { + // We're at the start of a new TOTP period, reset the usage flag + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + } + + // Additional safety check: if we have a timestamp and 30+ seconds have passed, unblock + if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { + let timeSinceLastUsed = now - lastUsed + if timeSinceLastUsed >= 30 { + Storage.shared.loopAPNSTOTPUsed.value = false + Storage.shared.loopAPNSTOTPLastUsed.value = nil + } + } + + otpTimeRemaining = newOtpTimeRemaining } .alert(isPresented: $showAlert) { switch alertType { @@ -346,6 +419,9 @@ struct LoopAPNSCarbsView: View { DispatchQueue.main.async { isLoading = false if success { + // Mark TOTP code as used with timestamp + Storage.shared.loopAPNSTOTPUsed.value = true + Storage.shared.loopAPNSTOTPLastUsed.value = Date().timeIntervalSince1970 let timeFormatter = DateFormatter() timeFormatter.timeStyle = .short alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!" diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 2f5ca2828..45e27ee00 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -168,6 +168,11 @@ class Storage { var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") + // MARK: - Loop APNS TOTP Usage Tracking + + var loopAPNSTOTPUsed = StorageValue(key: "loopAPNSTOTPUsed", defaultValue: false) + var loopAPNSTOTPLastUsed = StorageValue(key: "loopAPNSTOTPLastUsed", defaultValue: nil) + static let shared = Storage() private init() {} } From 07ae3f7b3c7a51ed6f621fa11a895c9311f8c611 Mon Sep 17 00:00:00 2001 From: codebymini Date: Mon, 11 Aug 2025 15:28:03 +0200 Subject: [PATCH 2/2] Clean up and last TOTP checking --- LoopFollow.xcodeproj/project.pbxproj | 8 +++- .../Remote/LoopAPNS/LoopAPNSBolusView.swift | 39 +++---------------- .../Remote/LoopAPNS/LoopAPNSCarbsView.swift | 39 +++---------------- LoopFollow/Remote/LoopAPNS/TOTPService.swift | 37 ++++++++++++++++++ LoopFollow/Storage/Observable.swift | 4 ++ LoopFollow/Storage/Storage.swift | 5 --- 6 files changed, 57 insertions(+), 75 deletions(-) create mode 100644 LoopFollow/Remote/LoopAPNS/TOTPService.swift diff --git a/LoopFollow.xcodeproj/project.pbxproj b/LoopFollow.xcodeproj/project.pbxproj index cb8e910d8..ea3a54623 100644 --- a/LoopFollow.xcodeproj/project.pbxproj +++ b/LoopFollow.xcodeproj/project.pbxproj @@ -10,9 +10,10 @@ 3F1335F351590E573D8E6962 /* Pods_LoopFollow.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */; }; 654132E72E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */; }; 654132EA2E19F24800BDBE08 /* TOTPGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */; }; - 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; }; 654134182E1DC09700BDBE08 /* OverridePresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */; }; 6541341A2E1DC27900BDBE08 /* OverridePresetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 654134192E1DC27900BDBE08 /* OverridePresetData.swift */; }; + 6541341C2E1DC28000BDBE08 /* DateExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */; }; + 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6584B1002E4A263900135D4D /* TOTPService.swift */; }; DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */; }; DD0247712DB4337700FCADF6 /* BuildExpireCondition.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */; }; DD0650A92DCA8A10004D3B41 /* AlarmBGSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0650A82DCA8A10004D3B41 /* AlarmBGSection.swift */; }; @@ -393,9 +394,10 @@ 059B0FA59AABFE72FE13DDDA /* Pods-LoopFollow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-LoopFollow.release.xcconfig"; path = "Target Support Files/Pods-LoopFollow/Pods-LoopFollow.release.xcconfig"; sourceTree = ""; }; 654132E62E19EA7E00BDBE08 /* SimpleQRCodeScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleQRCodeScannerView.swift; sourceTree = ""; }; 654132E92E19F24800BDBE08 /* TOTPGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPGenerator.swift; sourceTree = ""; }; - 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; 654134172E1DC09700BDBE08 /* OverridePresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetsView.swift; sourceTree = ""; }; 654134192E1DC27900BDBE08 /* OverridePresetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverridePresetData.swift; sourceTree = ""; }; + 6541341B2E1DC28000BDBE08 /* DateExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtensions.swift; sourceTree = ""; }; + 6584B1002E4A263900135D4D /* TOTPService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPService.swift; sourceTree = ""; }; A7D55B42A22051DAD69E89D0 /* Pods_LoopFollow.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_LoopFollow.framework; sourceTree = BUILT_PRODUCTS_DIR; }; DD0247582DB2E89600FCADF6 /* AlarmCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmCondition.swift; sourceTree = ""; }; DD02475B2DB2E8FB00FCADF6 /* BuildExpireCondition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuildExpireCondition.swift; sourceTree = ""; }; @@ -1191,6 +1193,7 @@ DDEF503E2D479B8A00884336 /* LoopAPNS */ = { isa = PBXGroup; children = ( + 6584B1002E4A263900135D4D /* TOTPService.swift */, DDEF503F2D479B8A00884336 /* LoopAPNSService.swift */, DDEF50412D479BAA00884336 /* LoopAPNSCarbsView.swift */, DDEF50422D479BBA00884336 /* LoopAPNSBolusView.swift */, @@ -1936,6 +1939,7 @@ DD5334272C61668800062F9D /* InfoDisplaySettingsViewModel.swift in Sources */, DD0247592DB2E89600FCADF6 /* AlarmCondition.swift in Sources */, DD0650F32DCE9B3D004D3B41 /* MissedReadingEditor.swift in Sources */, + 6584B1012E4A263900135D4D /* TOTPService.swift in Sources */, DD48780E2C7B74A40048F05C /* TrioRemoteControlViewModel.swift in Sources */, DDEF503A2D31615000999A5D /* LogManager.swift in Sources */, DD4878172C7B75350048F05C /* BolusView.swift in Sources */, diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift index bb69778c7..f39bc0081 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSBolusView.swift @@ -26,24 +26,7 @@ struct LoopAPNSBolusView: View { // Computed property to check if TOTP should be blocked private var isTOTPBlocked: Bool { - guard Storage.shared.loopAPNSTOTPUsed.value else { return false } - - // If we have a timestamp, check if 30 seconds have passed - if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { - let timeSinceLastUsed = Date().timeIntervalSince1970 - lastUsed - if timeSinceLastUsed >= 30 { - // 30 seconds have passed, unblock - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil - return false - } - } else { - // No timestamp but flag is set - this shouldn't happen, but let's clean it up - Storage.shared.loopAPNSTOTPUsed.value = false - return false - } - - return true + TOTPService.shared.isTOTPBlocked(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) } enum AlertType { @@ -215,24 +198,13 @@ struct LoopAPNSBolusView: View { newOtpTimeRemaining > currentOtpTimeRemaining { // New TOTP code generated, reset the usage flag - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil + TOTPService.shared.resetTOTPUsage() } // Also check if we're at the very beginning of a new period (when time remaining is close to 30) if newOtpTimeRemaining >= 29 { // We're at the start of a new TOTP period, reset the usage flag - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil - } - - // Additional safety check: if we have a timestamp and 30+ seconds have passed, unblock - if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { - let timeSinceLastUsed = now - lastUsed - if timeSinceLastUsed >= 30 { - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil - } + TOTPService.shared.resetTOTPUsage() } otpTimeRemaining = newOtpTimeRemaining @@ -406,9 +378,8 @@ struct LoopAPNSBolusView: View { DispatchQueue.main.async { isLoading = false if success { - // Mark TOTP code as used with timestamp - Storage.shared.loopAPNSTOTPUsed.value = true - Storage.shared.loopAPNSTOTPLastUsed.value = Date().timeIntervalSince1970 + // Mark TOTP code as used + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) alertMessage = "Insulin sent successfully!" alertType = .success LogManager.shared.log( diff --git a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift index 5155fa2ea..b94d633c9 100644 --- a/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift +++ b/LoopFollow/Remote/LoopAPNS/LoopAPNSCarbsView.swift @@ -25,24 +25,7 @@ struct LoopAPNSCarbsView: View { // Computed property to check if TOTP should be blocked private var isTOTPBlocked: Bool { - guard Storage.shared.loopAPNSTOTPUsed.value else { return false } - - // If we have a timestamp, check if 30 seconds have passed - if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { - let timeSinceLastUsed = Date().timeIntervalSince1970 - lastUsed - if timeSinceLastUsed >= 30 { - // 30 seconds have passed, unblock - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil - return false - } - } else { - // No timestamp but flag is set - this shouldn't happen, but let's clean it up - Storage.shared.loopAPNSTOTPUsed.value = false - return false - } - - return true + TOTPService.shared.isTOTPBlocked(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) } enum AlertType { @@ -278,24 +261,13 @@ struct LoopAPNSCarbsView: View { newOtpTimeRemaining > currentOtpTimeRemaining { // New TOTP code generated, reset the usage flag - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil + TOTPService.shared.resetTOTPUsage() } // Also check if we're at the very beginning of a new period (when time remaining is close to 30) if newOtpTimeRemaining >= 29 { // We're at the start of a new TOTP period, reset the usage flag - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil - } - - // Additional safety check: if we have a timestamp and 30+ seconds have passed, unblock - if let lastUsed = Storage.shared.loopAPNSTOTPLastUsed.value { - let timeSinceLastUsed = now - lastUsed - if timeSinceLastUsed >= 30 { - Storage.shared.loopAPNSTOTPUsed.value = false - Storage.shared.loopAPNSTOTPLastUsed.value = nil - } + TOTPService.shared.resetTOTPUsage() } otpTimeRemaining = newOtpTimeRemaining @@ -419,9 +391,8 @@ struct LoopAPNSCarbsView: View { DispatchQueue.main.async { isLoading = false if success { - // Mark TOTP code as used with timestamp - Storage.shared.loopAPNSTOTPUsed.value = true - Storage.shared.loopAPNSTOTPLastUsed.value = Date().timeIntervalSince1970 + // Mark TOTP code as used + TOTPService.shared.markTOTPAsUsed(qrCodeURL: Storage.shared.loopAPNSQrCodeURL.value) let timeFormatter = DateFormatter() timeFormatter.timeStyle = .short alertMessage = "Carbs sent successfully for \(timeFormatter.string(from: adjustedConsumedDate))!" diff --git a/LoopFollow/Remote/LoopAPNS/TOTPService.swift b/LoopFollow/Remote/LoopAPNS/TOTPService.swift new file mode 100644 index 000000000..1146dc4d7 --- /dev/null +++ b/LoopFollow/Remote/LoopAPNS/TOTPService.swift @@ -0,0 +1,37 @@ +// LoopFollow +// TOTPService.swift +// Created by codebymini. + +import Foundation + +/// Service class for managing TOTP code usage and blocking logic +class TOTPService { + static let shared = TOTPService() + + private init() {} + + /// Checks if the current TOTP code is blocked (already used) + /// - Parameter qrCodeURL: The QR code URL to extract the current TOTP from + /// - Returns: True if the TOTP is blocked, false otherwise + func isTOTPBlocked(qrCodeURL: String) -> Bool { + guard let currentTOTP = TOTPGenerator.extractOTPFromURL(qrCodeURL) else { + return false + } + + // Check if the current TOTP code equals the last sent TOTP code + return currentTOTP == Observable.shared.lastSentTOTP.value + } + + /// Marks the current TOTP code as used + /// - Parameter qrCodeURL: The QR code URL to extract the current TOTP from + func markTOTPAsUsed(qrCodeURL: String) { + if let currentTOTP = TOTPGenerator.extractOTPFromURL(qrCodeURL) { + Observable.shared.lastSentTOTP.set(currentTOTP) + } + } + + /// Resets the TOTP usage tracking (called when a new TOTP period starts) + func resetTOTPUsage() { + Observable.shared.lastSentTOTP.set(nil) + } +} diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index fe6d39a03..cbabc5993 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -36,5 +36,9 @@ class Observable { var settingsPath = ObservableValue(default: NavigationPath()) + // MARK: - Loop APNS TOTP Tracking + + var lastSentTOTP = ObservableValue(default: nil) + private init() {} } diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 45e27ee00..2f5ca2828 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -168,11 +168,6 @@ class Storage { var loopAPNSQrCodeURL = StorageValue(key: "loopAPNSQrCodeURL", defaultValue: "") - // MARK: - Loop APNS TOTP Usage Tracking - - var loopAPNSTOTPUsed = StorageValue(key: "loopAPNSTOTPUsed", defaultValue: false) - var loopAPNSTOTPLastUsed = StorageValue(key: "loopAPNSTOTPLastUsed", defaultValue: nil) - static let shared = Storage() private init() {} }