From 9b5568cc10319a13fbd8f38a244b6bf7bce8bdf1 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Mon, 20 Oct 2025 14:34:20 -0400 Subject: [PATCH 01/21] Use expected metric and dimension keys for geofence event --- .../KlaviyoLocation/KlaviyoLocationManager.swift | 14 +++++++------- Sources/KlaviyoLocation/Models/Geofence.swift | 8 ++++++++ Sources/KlaviyoSwift/Models/Event.swift | 15 +++++++++------ 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 34bbbaf3..169a2bb2 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -92,20 +92,20 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { guard let region = region as? CLCircularRegion else { return } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User entered region \"\(region.identifier)\"") + Logger.geoservices.info("🌎 User entered region \"\(region.klaviyoLocationId ?? region.identifier)\"") } let enterEvent = Event( - name: .locationEvent(.enteredBoundary), + name: .locationEvent(.geofenceEnter), properties: [ - "boundaryIdentifier": region.identifier + "geofence_id": region.klaviyoLocationId ] ) Task { await MainActor.run { KlaviyoInternal.create(event: enterEvent) - geofencePublisher.send("Entered \(region.identifier)") + geofencePublisher.send("Entered \(region.klaviyoLocationId)") } } } @@ -113,13 +113,13 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { guard let region = region as? CLCircularRegion else { return } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User exited region \"\(region.identifier)\"") + Logger.geoservices.info("🌎 User exited region \"\(region.klaviyoLocationId ?? region.identifier)\"") } let exitEvent = Event( - name: .locationEvent(.exitedBoundary), + name: .locationEvent(.geofenceExit), properties: [ - "boundaryIdentifier": region.identifier + "geofence_id": region.klaviyoLocationId ] ) diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index 5f45a6f7..905c9220 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -87,4 +87,12 @@ extension CLCircularRegion { internal func toKlaviyoGeofence() throws -> Geofence { try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) } + + internal var klaviyoLocationId: String? { + do { + return try toKlaviyoGeofence().locationId + } catch { + return nil + } + } } diff --git a/Sources/KlaviyoSwift/Models/Event.swift b/Sources/KlaviyoSwift/Models/Event.swift index fd950989..7acb10ef 100644 --- a/Sources/KlaviyoSwift/Models/Event.swift +++ b/Sources/KlaviyoSwift/Models/Event.swift @@ -23,8 +23,9 @@ public struct Event: Equatable { } public enum LocationEvent: Equatable { - case enteredBoundary - case exitedBoundary + case geofenceEnter + case geofenceExit + case geofenceDwell } } @@ -103,10 +104,12 @@ extension Event.EventName { case .startedCheckoutMetric: return "Started Checkout" case let .locationEvent(type): switch type { - case .enteredBoundary: - return "Entered Location" - case .exitedBoundary: - return "Exited Location" + case .geofenceEnter: + return "$geofence_enter" + case .geofenceExit: + return "$geofence_exit" + case .geofenceDwell: + return "$geofence_dwell" } case let .customEvent(value): return "\(value)" } From 8ea7928470454942bd766d736d65f89de93cd3e3 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Mon, 20 Oct 2025 14:46:41 -0400 Subject: [PATCH 02/21] Fix test util --- Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift b/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift index 1d48e6d9..2306c1da 100644 --- a/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift +++ b/Tests/KlaviyoLocationTests/KlaviyoLocationTestUtils.swift @@ -5,10 +5,10 @@ // Created by Isobelle Lim on 1/27/25. // +@testable import KlaviyoCore import Combine import CoreLocation import Foundation -import KlaviyoCore @_spi(KlaviyoPrivate) @testable import KlaviyoSwift // MARK: - Test Constants @@ -51,7 +51,7 @@ extension KlaviyoEnvironment { SDKName: { "klaviyo-swift-sdk" }, SDKVersion: { "1.0.0" }, formsDataEnvironment: { nil }, - openURL: { _ in } + linkHandler: DeepLinkHandler() ) } } From d018f169b107690ae119b7d995aa9f5029b55be5 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Thu, 23 Oct 2025 14:29:48 -0400 Subject: [PATCH 03/21] Add optional dwell field on Geofence object --- Sources/KlaviyoLocation/Assets/Geofences.json | 6 ++++-- Sources/KlaviyoLocation/GeofenceService.swift | 4 +++- Sources/KlaviyoLocation/Models/Geofence.swift | 8 +++++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Sources/KlaviyoLocation/Assets/Geofences.json b/Sources/KlaviyoLocation/Assets/Geofences.json index d2c5e008..48cf1f95 100644 --- a/Sources/KlaviyoLocation/Assets/Geofences.json +++ b/Sources/KlaviyoLocation/Assets/Geofences.json @@ -6,7 +6,8 @@ "attributes": { "latitude": 40.7128, "longitude": -74.006, - "radius": 100 + "radius": 100, + "dwell": 1 } }, { @@ -24,7 +25,8 @@ "attributes": { "latitude": 40.758, "longitude": -73.9855, - "radius": 150 + "radius": 150, + "dwell": 3 } } ] diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 2d24e7da..441144d7 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -67,7 +67,8 @@ internal struct GeofenceService: GeofenceServiceProvider { id: "\(companyId):\(rawGeofence.id)", longitude: rawGeofence.attributes.longitude, latitude: rawGeofence.attributes.latitude, - radius: rawGeofence.attributes.radius + radius: rawGeofence.attributes.radius, + dwell: rawGeofence.attributes.dwell ) } @@ -94,5 +95,6 @@ private struct GeofenceJSON: Codable { let latitude: Double let longitude: Double let radius: Double + let dwell: Int? } } diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index 905c9220..cfbe4b74 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -24,6 +24,9 @@ public struct Geofence: Equatable, Hashable, Codable { /// Radius of the geofence in meters public let radius: Double + /// Optional time in seconds to trigger a dwell event after entering and staying in the geofence for this duration. If not provided, only enter and exit events will be emitted for the geofence. + public let dwell: Int? + /// Company ID to which this geofence belongs, extracted from the geofence ID. public var companyId: String { id.split(separator: ":").first.map(String.init) ?? "" @@ -41,18 +44,21 @@ public struct Geofence: Equatable, Hashable, Codable { /// - longitude: Longitude coordinate of the geofence center /// - latitude: Latitude coordinate of the geofence center /// - radius: Radius of the geofence in meters + /// - dwell: Optional dwell time in seconds. If provided, a dwell event will be triggered after entering and staying in the geofence for this duration /// - Throws: `GeofenceError.invalidIdFormat` if the ID doesn't match the expected format public init( id: String, longitude: Double, latitude: Double, radius: Double, + dwell: Int? ) throws { try Self.validateIdFormat(id) self.id = id self.longitude = longitude self.latitude = latitude self.radius = radius + self.dwell = dwell } /// Validates that the geofence ID follows the expected format: {companyId}:{UUID} @@ -85,7 +91,7 @@ public enum GeofenceError: Error { extension CLCircularRegion { internal func toKlaviyoGeofence() throws -> Geofence { - try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) + try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius, dwell: nil) } internal var klaviyoLocationId: String? { From 0bf2b675482f6cef4f9aa081f7cb2cb4655ff1d3 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Thu, 23 Oct 2025 14:48:47 -0400 Subject: [PATCH 04/21] Add default of 5 minutes --- Sources/KlaviyoLocation/Assets/Geofences.json | 2 +- Sources/KlaviyoLocation/Models/Geofence.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/KlaviyoLocation/Assets/Geofences.json b/Sources/KlaviyoLocation/Assets/Geofences.json index 48cf1f95..e0abbfb0 100644 --- a/Sources/KlaviyoLocation/Assets/Geofences.json +++ b/Sources/KlaviyoLocation/Assets/Geofences.json @@ -7,7 +7,7 @@ "latitude": 40.7128, "longitude": -74.006, "radius": 100, - "dwell": 1 + "dwell": 60 } }, { diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index cfbe4b74..ab1f2a45 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -24,8 +24,8 @@ public struct Geofence: Equatable, Hashable, Codable { /// Radius of the geofence in meters public let radius: Double - /// Optional time in seconds to trigger a dwell event after entering and staying in the geofence for this duration. If not provided, only enter and exit events will be emitted for the geofence. - public let dwell: Int? + /// Time in seconds to trigger a dwell event after entering and staying in the geofence for this duration + public let dwell: Int /// Company ID to which this geofence belongs, extracted from the geofence ID. public var companyId: String { @@ -44,7 +44,7 @@ public struct Geofence: Equatable, Hashable, Codable { /// - longitude: Longitude coordinate of the geofence center /// - latitude: Latitude coordinate of the geofence center /// - radius: Radius of the geofence in meters - /// - dwell: Optional dwell time in seconds. If provided, a dwell event will be triggered after entering and staying in the geofence for this duration + /// - dwell: Optional time in seconds to trigger a dwell event after entering and staying in the geofence for this duration. If not provided, time will default to 5 minutes. /// - Throws: `GeofenceError.invalidIdFormat` if the ID doesn't match the expected format public init( id: String, @@ -58,7 +58,7 @@ public struct Geofence: Equatable, Hashable, Codable { self.longitude = longitude self.latitude = latitude self.radius = radius - self.dwell = dwell + self.dwell = dwell ?? 300 } /// Validates that the geofence ID follows the expected format: {companyId}:{UUID} From 52115417e954a481fb65c7be66161b9e1cfeed11 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Thu, 23 Oct 2025 15:24:03 -0400 Subject: [PATCH 05/21] Implement dwell events --- .../KlaviyoGeofenceManager.swift | 7 ++ .../KlaviyoLocationManager.swift | 99 ++++++++++++++++++- 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift index dc37a7b9..e605107a 100644 --- a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift @@ -11,11 +11,16 @@ import OSLog internal class KlaviyoGeofenceManager { private let locationManager: LocationManagerProtocol + private weak var locationManagerDelegate: KlaviyoLocationManager? internal init(locationManager: LocationManagerProtocol) { self.locationManager = locationManager } + internal func setLocationManagerDelegate(_ delegate: KlaviyoLocationManager) { + locationManagerDelegate = delegate + } + internal func setupGeofencing() { guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { if #available(iOS 14.0, *) { @@ -67,6 +72,8 @@ internal class KlaviyoGeofenceManager { let regionsToAdd = remoteGeofences.subtracting(activeGeofences) await MainActor.run { + locationManagerDelegate?.updateDwellSettings(remoteGeofences) + for region in regionsToAdd { if #available(iOS 14.0, *) { Logger.geoservices.info("Start monitoring for region \(region.id)") diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 169a2bb2..31df96c8 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -19,6 +19,10 @@ public class KlaviyoLocationManager: NSObject { private let geofenceManager: KlaviyoGeofenceManager private let geofencePublisher: PassthroughSubject = .init() + private var geofenceDwellSettings: [String: Int] = [:] + private var dwellTimers: [String: Timer] = [:] + private var dwellEnterTimes: [String: Date] = [:] + internal init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { self.locationManager = locationManager ?? CLLocationManager() self.geofenceManager = geofenceManager ?? KlaviyoGeofenceManager(locationManager: self.locationManager) @@ -26,6 +30,7 @@ public class KlaviyoLocationManager: NSObject { super.init() self.locationManager.delegate = self self.locationManager.allowsBackgroundLocationUpdates = true + self.geofenceManager.setLocationManagerDelegate(self) } deinit { @@ -33,6 +38,13 @@ public class KlaviyoLocationManager: NSObject { locationManager.stopUpdatingLocation() locationManager.stopMonitoringSignificantLocationChanges() geofenceManager.destroyGeofencing() + + for timer in dwellTimers.values { + timer.invalidate() + } + dwellTimers.removeAll() + dwellEnterTimes.removeAll() + geofenceDwellSettings.removeAll() } @MainActor @@ -46,6 +58,13 @@ public class KlaviyoLocationManager: NSObject { internal func destroyGeofencing() { geofenceManager.destroyGeofencing() } + + internal func updateDwellSettings(_ geofences: Set) { + geofenceDwellSettings.removeAll() + for geofence in geofences { + geofenceDwellSettings[geofence.locationId] = geofence.dwell + } + } } extension KlaviyoLocationManager: CLLocationManagerDelegate { @@ -108,6 +127,12 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { geofencePublisher.send("Entered \(region.klaviyoLocationId)") } } + + Task { + await MainActor.run { + startDwellTimer(for: region) + } + } } public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { @@ -126,8 +151,80 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { Task { await MainActor.run { KlaviyoInternal.create(event: exitEvent) - geofencePublisher.send("Exited \(region.identifier)") + geofencePublisher.send("Exited \(region.klaviyoLocationId)") + } + } + + Task { + await MainActor.run { + cancelDwellTimer(for: region) } } } + + // MARK: Dwell Timer Management + + private func startDwellTimer(for region: CLCircularRegion) { + guard let regionId = region.klaviyoLocationId else { + if #available(iOS 14.0, *) { + Logger.geoservices.error("Received unexpected region without a klaviyoLocationId") + } + return + } + cancelDwellTimer(for: region) + guard let dwellSeconds = geofenceDwellSettings[regionId] else { + return + } + + dwellEnterTimes[regionId] = Date() + let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(dwellSeconds), repeats: false) { [weak self] _ in + self?.handleDwellTimerFired(for: regionId) + } + dwellTimers[regionId] = timer + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Started dwell timer for region \(regionId) with \(dwellSeconds) seconds") + } + } + + private func cancelDwellTimer(for region: CLCircularRegion) { + guard let regionId = region.klaviyoLocationId else { + if #available(iOS 14.0, *) { + Logger.geoservices.error("Received unexpected region without a klaviyoLocationId") + } + return + } + if let timer = dwellTimers[regionId] { + timer.invalidate() + dwellTimers.removeValue(forKey: regionId) + dwellEnterTimes.removeValue(forKey: regionId) + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Cancelled dwell timer for region \(regionId)") + } + } + } + + private func handleDwellTimerFired(for locationId: String) { + dwellTimers.removeValue(forKey: locationId) + dwellEnterTimes.removeValue(forKey: locationId) + + let dwellEvent = Event( + name: .locationEvent(.geofenceDwell), + properties: [ + "geofence_id": locationId + ] + ) + + Task { + await MainActor.run { + KlaviyoInternal.create(event: dwellEvent) + geofencePublisher.send("Dwelled in \(locationId)") + } + } + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Dwell event fired for region \(locationId)") + } + } } From 527860622fa965cdc508531020c3ed02fb4592be Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Thu, 23 Oct 2025 15:47:29 -0400 Subject: [PATCH 06/21] Simplify by unwrapping klaviyoLocationId at top level and use that in dwell timers --- .../KlaviyoLocationManager.swift | 80 +++++++++---------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 31df96c8..cfdc2c28 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -109,122 +109,122 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { // MARK: Geofencing public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) { - guard let region = region as? CLCircularRegion else { return } + guard let region = region as? CLCircularRegion, + let klaviyoLocationId = region.klaviyoLocationId else { + if #available(iOS 14.0, *) { + Logger.geoservices.warning("Received non-Klaviyo geofence notification. Skipping.") + } + return + } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User entered region \"\(region.klaviyoLocationId ?? region.identifier)\"") + Logger.geoservices.info("🌎 User entered region \"\(klaviyoLocationId)\"") } let enterEvent = Event( name: .locationEvent(.geofenceEnter), properties: [ - "geofence_id": region.klaviyoLocationId + "geofence_id": klaviyoLocationId ] ) Task { await MainActor.run { KlaviyoInternal.create(event: enterEvent) - geofencePublisher.send("Entered \(region.klaviyoLocationId)") + geofencePublisher.send("Entered \(klaviyoLocationId)") } } Task { await MainActor.run { - startDwellTimer(for: region) + startDwellTimer(for: klaviyoLocationId) } } } public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { - guard let region = region as? CLCircularRegion else { return } + guard let region = region as? CLCircularRegion, + let klaviyoLocationId = region.klaviyoLocationId else { + if #available(iOS 14.0, *) { + Logger.geoservices.warning("Received non-Klaviyo geofence notification. Skipping.") + } + return + } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User exited region \"\(region.klaviyoLocationId ?? region.identifier)\"") + Logger.geoservices.info("🌎 User exited region \"\(klaviyoLocationId)\"") } let exitEvent = Event( name: .locationEvent(.geofenceExit), properties: [ - "geofence_id": region.klaviyoLocationId + "geofence_id": klaviyoLocationId ] ) Task { await MainActor.run { KlaviyoInternal.create(event: exitEvent) - geofencePublisher.send("Exited \(region.klaviyoLocationId)") + geofencePublisher.send("Exited \(klaviyoLocationId)") } } Task { await MainActor.run { - cancelDwellTimer(for: region) + cancelDwellTimer(for: klaviyoLocationId) } } } // MARK: Dwell Timer Management - private func startDwellTimer(for region: CLCircularRegion) { - guard let regionId = region.klaviyoLocationId else { - if #available(iOS 14.0, *) { - Logger.geoservices.error("Received unexpected region without a klaviyoLocationId") - } - return - } - cancelDwellTimer(for: region) - guard let dwellSeconds = geofenceDwellSettings[regionId] else { + private func startDwellTimer(for klaviyoLocationId: String) { + cancelDwellTimer(for: klaviyoLocationId) + guard let dwellSeconds = geofenceDwellSettings[klaviyoLocationId] else { return } - dwellEnterTimes[regionId] = Date() + dwellEnterTimes[klaviyoLocationId] = Date() let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(dwellSeconds), repeats: false) { [weak self] _ in - self?.handleDwellTimerFired(for: regionId) + self?.handleDwellTimerFired(for: klaviyoLocationId) } - dwellTimers[regionId] = timer + dwellTimers[klaviyoLocationId] = timer if #available(iOS 14.0, *) { - Logger.geoservices.info("🕐 Started dwell timer for region \(regionId) with \(dwellSeconds) seconds") + Logger.geoservices.info("🕐 Started dwell timer for region \(klaviyoLocationId) with \(dwellSeconds) seconds") } } - private func cancelDwellTimer(for region: CLCircularRegion) { - guard let regionId = region.klaviyoLocationId else { - if #available(iOS 14.0, *) { - Logger.geoservices.error("Received unexpected region without a klaviyoLocationId") - } - return - } - if let timer = dwellTimers[regionId] { + private func cancelDwellTimer(for klaviyoLocationId: String) { + if let timer = dwellTimers[klaviyoLocationId] { timer.invalidate() - dwellTimers.removeValue(forKey: regionId) - dwellEnterTimes.removeValue(forKey: regionId) + dwellTimers.removeValue(forKey: klaviyoLocationId) + dwellEnterTimes.removeValue(forKey: klaviyoLocationId) if #available(iOS 14.0, *) { - Logger.geoservices.info("🕐 Cancelled dwell timer for region \(regionId)") + Logger.geoservices.info("🕐 Cancelled dwell timer for region \(klaviyoLocationId)") } } } - private func handleDwellTimerFired(for locationId: String) { - dwellTimers.removeValue(forKey: locationId) - dwellEnterTimes.removeValue(forKey: locationId) + private func handleDwellTimerFired(for klaviyoLocationId: String) { + dwellTimers.removeValue(forKey: klaviyoLocationId) + dwellEnterTimes.removeValue(forKey: klaviyoLocationId) let dwellEvent = Event( name: .locationEvent(.geofenceDwell), properties: [ - "geofence_id": locationId + "geofence_id": klaviyoLocationId ] ) Task { await MainActor.run { KlaviyoInternal.create(event: dwellEvent) - geofencePublisher.send("Dwelled in \(locationId)") + geofencePublisher.send("Dwelled in \(klaviyoLocationId)") } } if #available(iOS 14.0, *) { - Logger.geoservices.info("🕐 Dwell event fired for region \(locationId)") + Logger.geoservices.info("🕐 Dwell event fired for region \(klaviyoLocationId)") } } } From dfcb9e577b31380b3f5afee3566cfdd1daf2e5af Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Thu, 23 Oct 2025 15:51:53 -0400 Subject: [PATCH 07/21] Remove unnecessary dwellEnterTimes --- Sources/KlaviyoLocation/KlaviyoLocationManager.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index cfdc2c28..8cf4d90f 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -21,7 +21,6 @@ public class KlaviyoLocationManager: NSObject { private var geofenceDwellSettings: [String: Int] = [:] private var dwellTimers: [String: Timer] = [:] - private var dwellEnterTimes: [String: Date] = [:] internal init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { self.locationManager = locationManager ?? CLLocationManager() @@ -43,7 +42,6 @@ public class KlaviyoLocationManager: NSObject { timer.invalidate() } dwellTimers.removeAll() - dwellEnterTimes.removeAll() geofenceDwellSettings.removeAll() } @@ -182,7 +180,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { return } - dwellEnterTimes[klaviyoLocationId] = Date() let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(dwellSeconds), repeats: false) { [weak self] _ in self?.handleDwellTimerFired(for: klaviyoLocationId) } @@ -197,7 +194,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { if let timer = dwellTimers[klaviyoLocationId] { timer.invalidate() dwellTimers.removeValue(forKey: klaviyoLocationId) - dwellEnterTimes.removeValue(forKey: klaviyoLocationId) if #available(iOS 14.0, *) { Logger.geoservices.info("🕐 Cancelled dwell timer for region \(klaviyoLocationId)") @@ -207,7 +203,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { private func handleDwellTimerFired(for klaviyoLocationId: String) { dwellTimers.removeValue(forKey: klaviyoLocationId) - dwellEnterTimes.removeValue(forKey: klaviyoLocationId) let dwellEvent = Event( name: .locationEvent(.geofenceDwell), From b729d931565ee74ca4d927247412d2505fc68b3e Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Fri, 24 Oct 2025 10:30:43 -0400 Subject: [PATCH 08/21] Don't actually change the apiUrl on the environment to mock fetch --- Sources/KlaviyoLocation/GeofenceService.swift | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 441144d7..d9965b0a 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -29,32 +29,45 @@ internal struct GeofenceService: GeofenceServiceProvider { /// Fetches raw geofence data from the API private func fetchGeofenceData() async throws -> Data { - // FIXME: Temporarily override the environment's API URL for this mock request - let originalAPIURL = environment.apiURL - environment.apiURL = { - var components = URLComponents() - components.scheme = "https" - components.host = "mock-api.com" - return components + // TODO: uncomment this block when we can use the real endpoint +// let endpoint = KlaviyoEndpoint.fetchGeofences +// let klaviyoRequest = KlaviyoRequest(endpoint: endpoint) +// let attemptInfo = try RequestAttemptInfo(attemptNumber: 1, maxAttempts: 1) +// let result = await environment.klaviyoAPI.send(klaviyoRequest, attemptInfo) +// +// switch result { +// case let .success(data): +// if #available(iOS 14.0, *) { +// Logger.geoservices.info("Successfully fetched geofences") +// } +// return data +// case let .failure(error): +// if #available(iOS 14.0, *) { +// Logger.geoservices.error("Failed to fetch geofences") +// } +// throw error +// } + + // TODO: mocks request with proxyman map local, remove this block when we can use the real endpoint + guard let url = URL(string: "https://mock-api.com/geofences") else { + throw NSError(domain: "GeofenceService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) } - let endpoint = KlaviyoEndpoint.fetchGeofences - let klaviyoRequest = KlaviyoRequest(endpoint: endpoint) - let attemptInfo = try RequestAttemptInfo(attemptNumber: 1, maxAttempts: 1) - let result = await environment.klaviyoAPI.send(klaviyoRequest, attemptInfo) + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") - // FIXME: Restore the original API URL - environment.apiURL = originalAPIURL + let (data, response) = try await URLSession.shared.data(for: request) - switch result { - case let .success(data): - return data - case let .failure(error): - if #available(iOS 14.0, *) { - Logger.geoservices.error("Failed to fetch geofences from mock endpoint https://mock-api.com/geofences") - } - throw error + guard let httpResponse = response as? HTTPURLResponse else { + throw NSError(domain: "GeofenceService", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) + } + + guard httpResponse.statusCode == 200 else { + throw NSError(domain: "GeofenceService", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP error: \(httpResponse.statusCode)"]) } + + return data } /// Parses raw geofence data and transforms it into Geofence objects with the companyId prepended to the id From c18fcd42e78fd3e2c84582e2842fbf704e1302fc Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Fri, 24 Oct 2025 11:37:28 -0400 Subject: [PATCH 09/21] Make dwell optional with default 5 min --- Sources/KlaviyoLocation/Models/Geofence.swift | 6 +++--- Tests/KlaviyoLocationTests/GeofenceTests.swift | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index ab1f2a45..c2050cff 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -25,7 +25,7 @@ public struct Geofence: Equatable, Hashable, Codable { public let radius: Double /// Time in seconds to trigger a dwell event after entering and staying in the geofence for this duration - public let dwell: Int + public let dwell: Int? /// Company ID to which this geofence belongs, extracted from the geofence ID. public var companyId: String { @@ -51,14 +51,14 @@ public struct Geofence: Equatable, Hashable, Codable { longitude: Double, latitude: Double, radius: Double, - dwell: Int? + dwell: Int? = 300 ) throws { try Self.validateIdFormat(id) self.id = id self.longitude = longitude self.latitude = latitude self.radius = radius - self.dwell = dwell ?? 300 + self.dwell = dwell } /// Validates that the geofence ID follows the expected format: {companyId}:{UUID} diff --git a/Tests/KlaviyoLocationTests/GeofenceTests.swift b/Tests/KlaviyoLocationTests/GeofenceTests.swift index 5b3fc8ac..4d05d107 100644 --- a/Tests/KlaviyoLocationTests/GeofenceTests.swift +++ b/Tests/KlaviyoLocationTests/GeofenceTests.swift @@ -20,7 +20,8 @@ final class GeofenceTests: XCTestCase { id: "ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", longitude: -122.03026995144546, latitude: 37.33204742438631, - radius: 100.0 + radius: 100.0, + dwell: nil ) XCTAssertEqual(geofence.id, "ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") @@ -29,6 +30,18 @@ final class GeofenceTests: XCTestCase { XCTAssertEqual(geofence.radius, 100.0) XCTAssertEqual(geofence.companyId, "ABC123") XCTAssertEqual(geofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertNil(geofence.dwell) + } + + func testGeofenceInitializationDefaultDwell() throws { + let geofence = try Geofence( + id: "ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", + longitude: -122.03026995144546, + latitude: 37.33204742438631, + radius: 100.0 + ) + + XCTAssertEqual(geofence.dwell, 300) } // MARK: - Core Location Conversion Tests @@ -133,7 +146,8 @@ final class GeofenceTests: XCTestCase { id: "8db4effa-44f1-45e6-a88d-8e7d50516a0f", longitude: -74.006, latitude: 40.7128, - radius: 100.0 + radius: 100.0, + dwell: nil )) { error in if case .invalidIdFormat = error as? GeofenceError { // Test passes if we get the expected error type From 915ab6aac6ffbe5939c353fdd24bda63c54bed62 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Fri, 24 Oct 2025 11:37:47 -0400 Subject: [PATCH 10/21] Immediately flush queue for geofence events --- Sources/KlaviyoSwift/Models/Event.swift | 10 ++++++++++ .../StateManagement/StateManagement.swift | 9 +++++---- Tests/KlaviyoSwiftTests/EventBufferTests.swift | 13 +++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/KlaviyoSwift/Models/Event.swift b/Sources/KlaviyoSwift/Models/Event.swift index 7acb10ef..71b6a96c 100644 --- a/Sources/KlaviyoSwift/Models/Event.swift +++ b/Sources/KlaviyoSwift/Models/Event.swift @@ -35,6 +35,16 @@ public struct Event: Equatable { public init(name: EventName) { self.name = name } + + /// Returns true if this event is a geofence-related event + public var isGeofenceEvent: Bool { + switch name { + case .locationEvent(.geofenceEnter), .locationEvent(.geofenceExit), .locationEvent(.geofenceDwell): + return true + default: + return false + } + } } struct Identifiers: Equatable { diff --git a/Sources/KlaviyoSwift/StateManagement/StateManagement.swift b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift index 219c8759..2849df87 100644 --- a/Sources/KlaviyoSwift/StateManagement/StateManagement.swift +++ b/Sources/KlaviyoSwift/StateManagement/StateManagement.swift @@ -140,8 +140,8 @@ enum KlaviyoAction: Equatable { var requiresInitialization: Bool { switch self { - // if event metric is opened push we DON'T require initilization in all other event metric cases we DO. - case let .enqueueEvent(event) where event.metric.name == ._openedPush: + // if event metric is opened push or geofence events we DON'T require initialization + case let .enqueueEvent(event) where event.metric.name == ._openedPush || event.metric.isGeofenceEvent: return false case .enqueueAggregateEvent, .enqueueEvent, .enqueueProfile, .resetProfile, .resetStateAndDequeue, .setBadgeCount, .setEmail, .setExternalId, .setPhoneNumber, .setProfileProperty, .setPushEnablement, .setPushToken: @@ -504,11 +504,12 @@ struct KlaviyoReducer: ReducerProtocol { state.enqueueRequest(request: request) /* - if we receive an opened push event we want to flush the queue right away so that + if we receive an opened push event or geofence events we want to flush the queue right away so that we don't miss any user engagement events. In all other cases we will flush the queue using the flush intervals defined above in `StateManagementConstants` */ - let baseEffect = event.metric.name == ._openedPush ? EffectTask.task { .flushQueue } : .none + let baseEffect = event.metric.name == ._openedPush || event.metric.isGeofenceEvent + ? EffectTask.task { .flushQueue } : .none return .merge([ baseEffect, .fireAndForget { KlaviyoInternal.publishEvent(event) } diff --git a/Tests/KlaviyoSwiftTests/EventBufferTests.swift b/Tests/KlaviyoSwiftTests/EventBufferTests.swift index 11674cad..eb35eb5a 100644 --- a/Tests/KlaviyoSwiftTests/EventBufferTests.swift +++ b/Tests/KlaviyoSwiftTests/EventBufferTests.swift @@ -5,6 +5,7 @@ // Created by Ajay Subramanya on 10/7/25. // +@testable import KlaviyoLocation @testable import KlaviyoSwift import Foundation import XCTest @@ -269,4 +270,16 @@ class EventBufferTests: XCTestCase { XCTAssertEqual(events.first?.metric.name.value, "$opened_push") XCTAssertEqual(events.first?.properties["message_id"] as? String, "abc123") } + + func testBufferWithLocationEvent() { + let openedPushEvent = Event(name: .locationEvent(.geofenceEnter)) + + // When + eventBuffer.buffer(openedPushEvent) + let events = eventBuffer.getRecentEvents() + + // Then + XCTAssertEqual(events.count, 1) + XCTAssertEqual(events.first?.metric.name.value, "$geofence_enter") + } } From 9e58bb19517d9b2d51c1de0e231bc84000c705be Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 28 Oct 2025 09:26:04 -0400 Subject: [PATCH 11/21] Add helper in KlaviyoSDK+Location to ensure background support --- Sources/KlaviyoLocation/KlaviyoSDK+Location.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift index 9eb0edd8..dad7384f 100644 --- a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift +++ b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift @@ -5,6 +5,7 @@ // Created by Isobelle Lim on 8/27/25. // +import CoreLocation import Foundation import KlaviyoSwift @@ -20,6 +21,12 @@ extension KlaviyoSDK { } } + /// To be called in didFinishLaunchingWithOptions to ensure geofence events that happen in a backgrounded/terminated state are processed. + public func monitorGeofencesFromBackground() { + let locationManager = CLLocationManager() + locationManager.delegate = KlaviyoLocationManager.shared + } + /// Unregisters app for geofencing. Stops monitoring for geofences and cleans up resources. @MainActor public func unregisterGeofencing() { From 183bc4e6e7b17a56269bcbd8cf363073edfe54e0 Mon Sep 17 00:00:00 2001 From: Belle Lim Date: Thu, 30 Oct 2025 16:18:44 -0400 Subject: [PATCH 12/21] Hook up real fetch geofences endpoint (#438) * Use client/geofences and add apiKey to request * Use 2025-10-15.pre api revision * Remove mock call and mock data * Fix tests * include error message in log Co-authored-by: Andrew Balmer --------- Co-authored-by: Andrew Balmer --- .../Networking/KlaviyoEndpoint.swift | 9 ++-- .../Networking/NetworkSession.swift | 2 +- Sources/KlaviyoLocation/Assets/Geofences.json | 33 ------------ Sources/KlaviyoLocation/GeofenceService.swift | 53 ++++++------------- .../KlaviyoEndpointTests.swift | 4 +- .../testCreateEmphemeralSesionHeaders.1.txt | 2 +- 6 files changed, 25 insertions(+), 78 deletions(-) delete mode 100644 Sources/KlaviyoLocation/Assets/Geofences.json diff --git a/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift index aa465620..3cbe40d7 100644 --- a/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift +++ b/Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift @@ -17,7 +17,7 @@ public enum KlaviyoEndpoint: Equatable, Codable { case aggregateEvent(_ apiKey: String, _ payload: AggregateEventPayload) case resolveDestinationURL(trackingLink: URL, profileInfo: ProfilePayload) case logTrackingLinkClicked(trackingLink: URL, clickTime: Date, profileInfo: ProfilePayload) - case fetchGeofences + case fetchGeofences(_ apiKey: String) private enum HeaderKey { static let profileInfo = "X-Klaviyo-Profile-Info" @@ -50,9 +50,10 @@ public enum KlaviyoEndpoint: Equatable, Codable { let .createEvent(apiKey, _), let .registerPushToken(apiKey, _), let .unregisterPushToken(apiKey, _), - let .aggregateEvent(apiKey, _): + let .aggregateEvent(apiKey, _), + let .fetchGeofences(apiKey): return [URLQueryItem(name: "company_id", value: apiKey)] - case .resolveDestinationURL, .logTrackingLinkClicked, .fetchGeofences: + case .resolveDestinationURL, .logTrackingLinkClicked: return [] } } @@ -114,7 +115,7 @@ public enum KlaviyoEndpoint: Equatable, Codable { case let .resolveDestinationURL(trackingLink, _), let .logTrackingLinkClicked(trackingLink, _, _): return trackingLink.path case .fetchGeofences: - return "/geofences" + return "/client/geofences" } } diff --git a/Sources/KlaviyoCore/Networking/NetworkSession.swift b/Sources/KlaviyoCore/Networking/NetworkSession.swift index 82dac4cf..5af83d0e 100644 --- a/Sources/KlaviyoCore/Networking/NetworkSession.swift +++ b/Sources/KlaviyoCore/Networking/NetworkSession.swift @@ -28,7 +28,7 @@ public struct NetworkSession { self.data = data } - fileprivate static let currentApiRevision = "2025-07-15" + fileprivate static let currentApiRevision = "2025-10-15.pre" fileprivate static let applicationJson = "application/json" fileprivate static let acceptedEncodings = ["br", "gzip", "deflate"] fileprivate static let mobileHeader = "1" diff --git a/Sources/KlaviyoLocation/Assets/Geofences.json b/Sources/KlaviyoLocation/Assets/Geofences.json deleted file mode 100644 index e0abbfb0..00000000 --- a/Sources/KlaviyoLocation/Assets/Geofences.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "data": [ - { - "type": "geofence", - "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", - "attributes": { - "latitude": 40.7128, - "longitude": -74.006, - "radius": 100, - "dwell": 60 - } - }, - { - "type": "geofence", - "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", - "attributes": { - "latitude": 40.6892, - "longitude": -74.0445, - "radius": 200 - } - }, - { - "type": "geofence", - "id": "e1ded9eb-ba47-453d-afba-3f462bc87576", - "attributes": { - "latitude": 40.758, - "longitude": -73.9855, - "radius": 150, - "dwell": 3 - } - } - ] -} diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index d9965b0a..8e7c07e6 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -29,45 +29,24 @@ internal struct GeofenceService: GeofenceServiceProvider { /// Fetches raw geofence data from the API private func fetchGeofenceData() async throws -> Data { - // TODO: uncomment this block when we can use the real endpoint -// let endpoint = KlaviyoEndpoint.fetchGeofences -// let klaviyoRequest = KlaviyoRequest(endpoint: endpoint) -// let attemptInfo = try RequestAttemptInfo(attemptNumber: 1, maxAttempts: 1) -// let result = await environment.klaviyoAPI.send(klaviyoRequest, attemptInfo) -// -// switch result { -// case let .success(data): -// if #available(iOS 14.0, *) { -// Logger.geoservices.info("Successfully fetched geofences") -// } -// return data -// case let .failure(error): -// if #available(iOS 14.0, *) { -// Logger.geoservices.error("Failed to fetch geofences") -// } -// throw error -// } - - // TODO: mocks request with proxyman map local, remove this block when we can use the real endpoint - guard let url = URL(string: "https://mock-api.com/geofences") else { - throw NSError(domain: "GeofenceService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]) - } - - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw NSError(domain: "GeofenceService", code: -2, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) - } + let apiKey = try await KlaviyoInternal.fetchAPIKey() + let endpoint = KlaviyoEndpoint.fetchGeofences(apiKey) + let klaviyoRequest = KlaviyoRequest(endpoint: endpoint) + let attemptInfo = try RequestAttemptInfo(attemptNumber: 1, maxAttempts: 1) + let result = await environment.klaviyoAPI.send(klaviyoRequest, attemptInfo) - guard httpResponse.statusCode == 200 else { - throw NSError(domain: "GeofenceService", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "HTTP error: \(httpResponse.statusCode)"]) + switch result { + case let .success(data): + if #available(iOS 14.0, *) { + Logger.geoservices.info("Successfully fetched geofences") + } + return data + case let .failure(error): + if #available(iOS 14.0, *) { + Logger.geoservices.error("Failed to fetch geofences; error: \(error)") + } + throw error } - - return data } /// Parses raw geofence data and transforms it into Geofence objects with the companyId prepended to the id diff --git a/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift b/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift index d8af2696..cadb1926 100644 --- a/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift +++ b/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift @@ -180,13 +180,13 @@ final class KlaviyoEndpointTests: XCTestCase { func testFetchGeofencesEndpointUrlRequest() throws { // Given let apiKey = "test_api_key" - let endpoint = KlaviyoEndpoint.fetchGeofences + let endpoint = KlaviyoEndpoint.fetchGeofences(apiKey) // When let request = try endpoint.urlRequest() // Then XCTAssertEqual(request.httpMethod, "GET") - XCTAssertEqual(request.url?.path, "/geofences") + XCTAssertEqual(request.url?.path, "/client/geofences") } } diff --git a/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt index 5f9d8522..6dc84941 100644 --- a/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt +++ b/Tests/KlaviyoCoreTests/__Snapshots__/NetworkSessionTests/testCreateEmphemeralSesionHeaders.1.txt @@ -20,4 +20,4 @@ - value: "application/json" ▿ (2 elements) - key: "revision" - - value: "2025-07-15" + - value: "2025-10-15.pre" From 55049a2ef810377c581e8e7a3a89506bbce01c7d Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Mon, 3 Nov 2025 14:30:16 -0500 Subject: [PATCH 13/21] Remove dwell (for now) --- Sources/KlaviyoLocation/Assets/Geofences.json | 31 ++++++++ Sources/KlaviyoLocation/GeofenceService.swift | 4 +- .../KlaviyoGeofenceManager.swift | 2 - .../KlaviyoLocationManager.swift | 79 ------------------- Sources/KlaviyoLocation/Models/Geofence.swift | 10 +-- .../KlaviyoLocationTests/GeofenceTests.swift | 18 +---- 6 files changed, 36 insertions(+), 108 deletions(-) create mode 100644 Sources/KlaviyoLocation/Assets/Geofences.json diff --git a/Sources/KlaviyoLocation/Assets/Geofences.json b/Sources/KlaviyoLocation/Assets/Geofences.json new file mode 100644 index 00000000..d2c5e008 --- /dev/null +++ b/Sources/KlaviyoLocation/Assets/Geofences.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "type": "geofence", + "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", + "attributes": { + "latitude": 40.7128, + "longitude": -74.006, + "radius": 100 + } + }, + { + "type": "geofence", + "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", + "attributes": { + "latitude": 40.6892, + "longitude": -74.0445, + "radius": 200 + } + }, + { + "type": "geofence", + "id": "e1ded9eb-ba47-453d-afba-3f462bc87576", + "attributes": { + "latitude": 40.758, + "longitude": -73.9855, + "radius": 150 + } + } + ] +} diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 8e7c07e6..95f0d314 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -59,8 +59,7 @@ internal struct GeofenceService: GeofenceServiceProvider { id: "\(companyId):\(rawGeofence.id)", longitude: rawGeofence.attributes.longitude, latitude: rawGeofence.attributes.latitude, - radius: rawGeofence.attributes.radius, - dwell: rawGeofence.attributes.dwell + radius: rawGeofence.attributes.radius ) } @@ -87,6 +86,5 @@ private struct GeofenceJSON: Codable { let latitude: Double let longitude: Double let radius: Double - let dwell: Int? } } diff --git a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift index e605107a..a7e1643c 100644 --- a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift @@ -72,8 +72,6 @@ internal class KlaviyoGeofenceManager { let regionsToAdd = remoteGeofences.subtracting(activeGeofences) await MainActor.run { - locationManagerDelegate?.updateDwellSettings(remoteGeofences) - for region in regionsToAdd { if #available(iOS 14.0, *) { Logger.geoservices.info("Start monitoring for region \(region.id)") diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 8cf4d90f..73d44fc3 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -19,9 +19,6 @@ public class KlaviyoLocationManager: NSObject { private let geofenceManager: KlaviyoGeofenceManager private let geofencePublisher: PassthroughSubject = .init() - private var geofenceDwellSettings: [String: Int] = [:] - private var dwellTimers: [String: Timer] = [:] - internal init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { self.locationManager = locationManager ?? CLLocationManager() self.geofenceManager = geofenceManager ?? KlaviyoGeofenceManager(locationManager: self.locationManager) @@ -37,12 +34,6 @@ public class KlaviyoLocationManager: NSObject { locationManager.stopUpdatingLocation() locationManager.stopMonitoringSignificantLocationChanges() geofenceManager.destroyGeofencing() - - for timer in dwellTimers.values { - timer.invalidate() - } - dwellTimers.removeAll() - geofenceDwellSettings.removeAll() } @MainActor @@ -56,13 +47,6 @@ public class KlaviyoLocationManager: NSObject { internal func destroyGeofencing() { geofenceManager.destroyGeofencing() } - - internal func updateDwellSettings(_ geofences: Set) { - geofenceDwellSettings.removeAll() - for geofence in geofences { - geofenceDwellSettings[geofence.locationId] = geofence.dwell - } - } } extension KlaviyoLocationManager: CLLocationManagerDelegate { @@ -131,12 +115,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { geofencePublisher.send("Entered \(klaviyoLocationId)") } } - - Task { - await MainActor.run { - startDwellTimer(for: klaviyoLocationId) - } - } } public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) { @@ -164,62 +142,5 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { geofencePublisher.send("Exited \(klaviyoLocationId)") } } - - Task { - await MainActor.run { - cancelDwellTimer(for: klaviyoLocationId) - } - } - } - - // MARK: Dwell Timer Management - - private func startDwellTimer(for klaviyoLocationId: String) { - cancelDwellTimer(for: klaviyoLocationId) - guard let dwellSeconds = geofenceDwellSettings[klaviyoLocationId] else { - return - } - - let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(dwellSeconds), repeats: false) { [weak self] _ in - self?.handleDwellTimerFired(for: klaviyoLocationId) - } - dwellTimers[klaviyoLocationId] = timer - - if #available(iOS 14.0, *) { - Logger.geoservices.info("🕐 Started dwell timer for region \(klaviyoLocationId) with \(dwellSeconds) seconds") - } - } - - private func cancelDwellTimer(for klaviyoLocationId: String) { - if let timer = dwellTimers[klaviyoLocationId] { - timer.invalidate() - dwellTimers.removeValue(forKey: klaviyoLocationId) - - if #available(iOS 14.0, *) { - Logger.geoservices.info("🕐 Cancelled dwell timer for region \(klaviyoLocationId)") - } - } - } - - private func handleDwellTimerFired(for klaviyoLocationId: String) { - dwellTimers.removeValue(forKey: klaviyoLocationId) - - let dwellEvent = Event( - name: .locationEvent(.geofenceDwell), - properties: [ - "geofence_id": klaviyoLocationId - ] - ) - - Task { - await MainActor.run { - KlaviyoInternal.create(event: dwellEvent) - geofencePublisher.send("Dwelled in \(klaviyoLocationId)") - } - } - - if #available(iOS 14.0, *) { - Logger.geoservices.info("🕐 Dwell event fired for region \(klaviyoLocationId)") - } } } diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index c2050cff..c52bcdd9 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -24,9 +24,6 @@ public struct Geofence: Equatable, Hashable, Codable { /// Radius of the geofence in meters public let radius: Double - /// Time in seconds to trigger a dwell event after entering and staying in the geofence for this duration - public let dwell: Int? - /// Company ID to which this geofence belongs, extracted from the geofence ID. public var companyId: String { id.split(separator: ":").first.map(String.init) ?? "" @@ -44,21 +41,18 @@ public struct Geofence: Equatable, Hashable, Codable { /// - longitude: Longitude coordinate of the geofence center /// - latitude: Latitude coordinate of the geofence center /// - radius: Radius of the geofence in meters - /// - dwell: Optional time in seconds to trigger a dwell event after entering and staying in the geofence for this duration. If not provided, time will default to 5 minutes. /// - Throws: `GeofenceError.invalidIdFormat` if the ID doesn't match the expected format public init( id: String, longitude: Double, latitude: Double, - radius: Double, - dwell: Int? = 300 + radius: Double ) throws { try Self.validateIdFormat(id) self.id = id self.longitude = longitude self.latitude = latitude self.radius = radius - self.dwell = dwell } /// Validates that the geofence ID follows the expected format: {companyId}:{UUID} @@ -91,7 +85,7 @@ public enum GeofenceError: Error { extension CLCircularRegion { internal func toKlaviyoGeofence() throws -> Geofence { - try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius, dwell: nil) + try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) } internal var klaviyoLocationId: String? { diff --git a/Tests/KlaviyoLocationTests/GeofenceTests.swift b/Tests/KlaviyoLocationTests/GeofenceTests.swift index 4d05d107..5b3fc8ac 100644 --- a/Tests/KlaviyoLocationTests/GeofenceTests.swift +++ b/Tests/KlaviyoLocationTests/GeofenceTests.swift @@ -20,8 +20,7 @@ final class GeofenceTests: XCTestCase { id: "ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", longitude: -122.03026995144546, latitude: 37.33204742438631, - radius: 100.0, - dwell: nil + radius: 100.0 ) XCTAssertEqual(geofence.id, "ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") @@ -30,18 +29,6 @@ final class GeofenceTests: XCTestCase { XCTAssertEqual(geofence.radius, 100.0) XCTAssertEqual(geofence.companyId, "ABC123") XCTAssertEqual(geofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f") - XCTAssertNil(geofence.dwell) - } - - func testGeofenceInitializationDefaultDwell() throws { - let geofence = try Geofence( - id: "ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", - longitude: -122.03026995144546, - latitude: 37.33204742438631, - radius: 100.0 - ) - - XCTAssertEqual(geofence.dwell, 300) } // MARK: - Core Location Conversion Tests @@ -146,8 +133,7 @@ final class GeofenceTests: XCTestCase { id: "8db4effa-44f1-45e6-a88d-8e7d50516a0f", longitude: -74.006, latitude: 40.7128, - radius: 100.0, - dwell: nil + radius: 100.0 )) { error in if case .invalidIdFormat = error as? GeofenceError { // Test passes if we get the expected error type From 47b7ed110ed75a68034c5ed7981381b66e874224 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Mon, 3 Nov 2025 14:41:44 -0500 Subject: [PATCH 14/21] Clean up warnings, helper functions, PR feedback --- Sources/KlaviyoLocation/KlaviyoLocationManager.swift | 5 +---- Sources/KlaviyoSwift/Models/Event.swift | 7 +------ 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 73d44fc3..2b8cdc38 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -17,7 +17,6 @@ public class KlaviyoLocationManager: NSObject { private var locationManager: LocationManagerProtocol private let geofenceManager: KlaviyoGeofenceManager - private let geofencePublisher: PassthroughSubject = .init() internal init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { self.locationManager = locationManager ?? CLLocationManager() @@ -94,7 +93,7 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { guard let region = region as? CLCircularRegion, let klaviyoLocationId = region.klaviyoLocationId else { if #available(iOS 14.0, *) { - Logger.geoservices.warning("Received non-Klaviyo geofence notification. Skipping.") + Logger.geoservices.info("Received non-Klaviyo geofence notification. Skipping.") } return } @@ -112,7 +111,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { Task { await MainActor.run { KlaviyoInternal.create(event: enterEvent) - geofencePublisher.send("Entered \(klaviyoLocationId)") } } } @@ -139,7 +137,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { Task { await MainActor.run { KlaviyoInternal.create(event: exitEvent) - geofencePublisher.send("Exited \(klaviyoLocationId)") } } } diff --git a/Sources/KlaviyoSwift/Models/Event.swift b/Sources/KlaviyoSwift/Models/Event.swift index 71b6a96c..642a45cb 100644 --- a/Sources/KlaviyoSwift/Models/Event.swift +++ b/Sources/KlaviyoSwift/Models/Event.swift @@ -38,12 +38,7 @@ public struct Event: Equatable { /// Returns true if this event is a geofence-related event public var isGeofenceEvent: Bool { - switch name { - case .locationEvent(.geofenceEnter), .locationEvent(.geofenceExit), .locationEvent(.geofenceDwell): - return true - default: - return false - } + if case .locationEvent = name { true } else { false } } } From 81c2f94dbaf8c27e03e728671a6b9a24fa1527ef Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Mon, 3 Nov 2025 15:54:51 -0500 Subject: [PATCH 15/21] startMonitoringSignificantLocationChanges should support terminated events --- .../KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist | 2 ++ Sources/KlaviyoLocation/KlaviyoLocationManager.swift | 8 ++------ Sources/KlaviyoLocation/KlaviyoSDK+Location.swift | 1 + 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist b/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist index 88c8f59a..7f6ba06c 100644 --- a/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist +++ b/Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist @@ -26,6 +26,8 @@ UIBackgroundModes remote-notification + location + fetch diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 2b8cdc38..97275624 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -13,7 +13,7 @@ import KlaviyoSwift import OSLog public class KlaviyoLocationManager: NSObject { - static let shared = KlaviyoLocationManager() + internal static let shared = KlaviyoLocationManager() private var locationManager: LocationManagerProtocol private let geofenceManager: KlaviyoGeofenceManager @@ -25,6 +25,7 @@ public class KlaviyoLocationManager: NSObject { super.init() self.locationManager.delegate = self self.locationManager.allowsBackgroundLocationUpdates = true + self.locationManager.startMonitoringSignificantLocationChanges() self.geofenceManager.setLocationManagerDelegate(self) } @@ -57,11 +58,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { } } - @available(iOS 14.0, *) - public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - handleCLAuthorizationStatusChange(manager, locationManager.currentAuthorizationStatus) - } - @available(iOS, deprecated: 14.0) public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { handleCLAuthorizationStatusChange(manager, status) diff --git a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift index dad7384f..7864b18d 100644 --- a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift +++ b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift @@ -25,6 +25,7 @@ extension KlaviyoSDK { public func monitorGeofencesFromBackground() { let locationManager = CLLocationManager() locationManager.delegate = KlaviyoLocationManager.shared + locationManager.allowsBackgroundLocationUpdates = true } /// Unregisters app for geofencing. Stops monitoring for geofences and cleans up resources. From 1d42799edda6b8df4986db3cced1e996e0bbab6e Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 4 Nov 2025 15:38:49 -0500 Subject: [PATCH 16/21] Add initialized check to fetchGeofences to protect initial launches --- Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift | 8 ++++++++ Sources/KlaviyoLocation/KlaviyoLocationManager.swift | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift index a7e1643c..d77a27a4 100644 --- a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift @@ -7,6 +7,7 @@ import CoreLocation import KlaviyoCore +import KlaviyoSwift import OSLog internal class KlaviyoGeofenceManager { @@ -37,6 +38,13 @@ internal class KlaviyoGeofenceManager { } Task { + guard let _ = try? await KlaviyoInternal.fetchAPIKey() else { + if #available(iOS 14.0, *) { + Logger.geoservices.info("SDK is not initialized, skipping geofence refresh") + } + return + } + await updateGeofences() } } diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 97275624..35095b32 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -58,6 +58,11 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { } } + @available(iOS 14.0, *) + public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + handleCLAuthorizationStatusChange(manager, locationManager.currentAuthorizationStatus) + } + @available(iOS, deprecated: 14.0) public func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { handleCLAuthorizationStatusChange(manager, status) From 4f2e5f7cafd63b79e9fc8494a96189ddaee8d40f Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 4 Nov 2025 15:40:38 -0500 Subject: [PATCH 17/21] OSLog privacy changes --- Sources/KlaviyoLocation/GeofenceService.swift | 2 +- Sources/KlaviyoLocation/KlaviyoLocationManager.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 95f0d314..b220afdd 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -43,7 +43,7 @@ internal struct GeofenceService: GeofenceServiceProvider { return data case let .failure(error): if #available(iOS 14.0, *) { - Logger.geoservices.error("Failed to fetch geofences; error: \(error)") + Logger.geoservices.error("Failed to fetch geofences; error: \(error, privacy: .public)") } throw error } diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 35095b32..7858f4df 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -99,7 +99,7 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { return } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User entered region \"\(klaviyoLocationId)\"") + Logger.geoservices.info("🌎 User entered region \"\(klaviyoLocationId, privacy: .public)\"") } let enterEvent = Event( @@ -125,7 +125,7 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { return } if #available(iOS 14.0, *) { - Logger.geoservices.info("🌎 User exited region \"\(klaviyoLocationId)\"") + Logger.geoservices.info("🌎 User exited region \"\(klaviyoLocationId, privacy: .public)\"") } let exitEvent = Event( From 5aa7e310fb759cd0d60ae330b77db3feea017edc Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Wed, 5 Nov 2025 10:39:28 -0500 Subject: [PATCH 18/21] Remove mock data --- Sources/KlaviyoLocation/Assets/Geofences.json | 31 ------------------- 1 file changed, 31 deletions(-) delete mode 100644 Sources/KlaviyoLocation/Assets/Geofences.json diff --git a/Sources/KlaviyoLocation/Assets/Geofences.json b/Sources/KlaviyoLocation/Assets/Geofences.json deleted file mode 100644 index d2c5e008..00000000 --- a/Sources/KlaviyoLocation/Assets/Geofences.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "data": [ - { - "type": "geofence", - "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", - "attributes": { - "latitude": 40.7128, - "longitude": -74.006, - "radius": 100 - } - }, - { - "type": "geofence", - "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", - "attributes": { - "latitude": 40.6892, - "longitude": -74.0445, - "radius": 200 - } - }, - { - "type": "geofence", - "id": "e1ded9eb-ba47-453d-afba-3f462bc87576", - "attributes": { - "latitude": 40.758, - "longitude": -73.9855, - "radius": 150 - } - } - ] -} From 58e189aff173e05d142c7e48a8673c0618171a55 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Thu, 6 Nov 2025 14:29:37 -0500 Subject: [PATCH 19/21] Remove locationManagerDelegate, reorganize, simplify background support --- .../KlaviyoGeofenceManager.swift | 34 ++++++++++++--- .../KlaviyoLocationManager.swift | 1 - .../KlaviyoLocation/KlaviyoSDK+Location.swift | 4 +- Sources/KlaviyoLocation/Models/Geofence.swift | 43 ++++--------------- 4 files changed, 39 insertions(+), 43 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift index d77a27a4..01e36be8 100644 --- a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift @@ -12,16 +12,11 @@ import OSLog internal class KlaviyoGeofenceManager { private let locationManager: LocationManagerProtocol - private weak var locationManagerDelegate: KlaviyoLocationManager? internal init(locationManager: LocationManagerProtocol) { self.locationManager = locationManager } - internal func setLocationManagerDelegate(_ delegate: KlaviyoLocationManager) { - locationManagerDelegate = delegate - } - internal func setupGeofencing() { guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { if #available(iOS 14.0, *) { @@ -98,3 +93,32 @@ internal class KlaviyoGeofenceManager { } } } + +// MARK: Data Type Conversions + +extension Geofence { + /// Converts this geofence to a Core Location circular region + /// - Returns: A CLCircularRegion instance + internal func toCLCircularRegion() -> CLCircularRegion { + let region = CLCircularRegion( + center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), + radius: radius, + identifier: id + ) + return region + } +} + +extension CLCircularRegion { + internal func toKlaviyoGeofence() throws -> Geofence { + try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) + } + + internal var klaviyoLocationId: String? { + do { + return try toKlaviyoGeofence().locationId + } catch { + return nil + } + } +} diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 7858f4df..f03a569a 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -26,7 +26,6 @@ public class KlaviyoLocationManager: NSObject { self.locationManager.delegate = self self.locationManager.allowsBackgroundLocationUpdates = true self.locationManager.startMonitoringSignificantLocationChanges() - self.geofenceManager.setLocationManagerDelegate(self) } deinit { diff --git a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift index 7864b18d..db86b105 100644 --- a/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift +++ b/Sources/KlaviyoLocation/KlaviyoSDK+Location.swift @@ -23,9 +23,7 @@ extension KlaviyoSDK { /// To be called in didFinishLaunchingWithOptions to ensure geofence events that happen in a backgrounded/terminated state are processed. public func monitorGeofencesFromBackground() { - let locationManager = CLLocationManager() - locationManager.delegate = KlaviyoLocationManager.shared - locationManager.allowsBackgroundLocationUpdates = true + _ = KlaviyoLocationManager.shared } /// Unregisters app for geofencing. Stops monitoring for geofences and cleans up resources. diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index c52bcdd9..6edf0027 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -11,26 +11,26 @@ import KlaviyoCore import KlaviyoSwift /// Represents a Klaviyo geofence -public struct Geofence: Equatable, Hashable, Codable { +internal struct Geofence: Equatable, Hashable, Codable { /// The geofence ID is a combination of the company ID and location ID from Klaviyo, separated by a colon. - public let id: String + internal let id: String /// Longitude of the geofence center - public let longitude: Double + internal let longitude: Double /// Latitude of the geofence center - public let latitude: Double + internal let latitude: Double /// Radius of the geofence in meters - public let radius: Double + internal let radius: Double /// Company ID to which this geofence belongs, extracted from the geofence ID. - public var companyId: String { + internal var companyId: String { id.split(separator: ":").first.map(String.init) ?? "" } /// Location UUID to which this geofence belongs, extracted from the geofence ID. - public var locationId: String { + internal var locationId: String { let components = id.split(separator: ":", maxSplits: 1) return components.count > 1 ? String(components[1]) : "" } @@ -42,7 +42,7 @@ public struct Geofence: Equatable, Hashable, Codable { /// - latitude: Latitude coordinate of the geofence center /// - radius: Radius of the geofence in meters /// - Throws: `GeofenceError.invalidIdFormat` if the ID doesn't match the expected format - public init( + internal init( id: String, longitude: Double, latitude: Double, @@ -65,34 +65,9 @@ public struct Geofence: Equatable, Hashable, Codable { throw GeofenceError.invalidIdFormat("ID must be in format '{companyId}:{geofenceUUID}', got: '\(id)'") } } - - /// Converts this geofence to a Core Location circular region - /// - Returns: A CLCircularRegion instance - public func toCLCircularRegion() -> CLCircularRegion { - let region = CLCircularRegion( - center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), - radius: radius, - identifier: id - ) - return region - } } /// Errors that can occur when working with geofences -public enum GeofenceError: Error { +internal enum GeofenceError: Error { case invalidIdFormat(String) } - -extension CLCircularRegion { - internal func toKlaviyoGeofence() throws -> Geofence { - try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) - } - - internal var klaviyoLocationId: String? { - do { - return try toKlaviyoGeofence().locationId - } catch { - return nil - } - } -} From fbd17f9eac29d8f9a743344332db7ff8f50f7857 Mon Sep 17 00:00:00 2001 From: Ajay Subramanya <118314354+ajaysubra@users.noreply.github.com> Date: Fri, 7 Nov 2025 08:37:45 -0600 Subject: [PATCH 20/21] Remove redundant internal keywords from KlaviyoLocation module (#443) - Changed KlaviyoLocationManager from public to internal class - Removed redundant internal keywords from all type declarations: - KlaviyoLocationManager, KlaviyoGeofenceManager - GeofenceServiceProvider, GeofenceService - Geofence, GeofenceError - LocationManagerProtocol - Removed redundant internal keywords from all members (properties, methods, inits) - Types now rely on Swift's default internal access level for clarity All 13 tests passing. --- Sources/KlaviyoLocation/GeofenceService.swift | 8 ++++---- .../KlaviyoGeofenceManager.swift | 14 +++++++------- .../KlaviyoLocationManager.swift | 10 +++++----- Sources/KlaviyoLocation/Models/Geofence.swift | 18 +++++++++--------- .../Utilities/CLAuthorizationStatus+Ext.swift | 2 +- ...cationManager+LocationManagerProtocol.swift | 2 +- .../KlaviyoLocation/Utilities/Logger+Ext.swift | 2 +- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index b220afdd..5ddcf457 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -10,12 +10,12 @@ import KlaviyoCore import KlaviyoSwift import OSLog -internal protocol GeofenceServiceProvider { +protocol GeofenceServiceProvider { func fetchGeofences() async -> Set } -internal struct GeofenceService: GeofenceServiceProvider { - internal func fetchGeofences() async -> Set { +struct GeofenceService: GeofenceServiceProvider { + func fetchGeofences() async -> Set { do { let data = try await fetchGeofenceData() return try await parseGeofences(from: data) @@ -50,7 +50,7 @@ internal struct GeofenceService: GeofenceServiceProvider { } /// Parses raw geofence data and transforms it into Geofence objects with the companyId prepended to the id - internal func parseGeofences(from data: Data) async throws -> Set { + func parseGeofences(from data: Data) async throws -> Set { do { let response = try JSONDecoder().decode(GeofenceJSONResponse.self, from: data) let companyId = try await KlaviyoInternal.fetchAPIKey() diff --git a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift index 01e36be8..3aa0b56e 100644 --- a/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift @@ -10,14 +10,14 @@ import KlaviyoCore import KlaviyoSwift import OSLog -internal class KlaviyoGeofenceManager { +class KlaviyoGeofenceManager { private let locationManager: LocationManagerProtocol - internal init(locationManager: LocationManagerProtocol) { + init(locationManager: LocationManagerProtocol) { self.locationManager = locationManager } - internal func setupGeofencing() { + func setupGeofencing() { guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else { if #available(iOS 14.0, *) { Logger.geoservices.warning("Geofencing is not supported on this device") @@ -44,7 +44,7 @@ internal class KlaviyoGeofenceManager { } } - internal func destroyGeofencing() { + func destroyGeofencing() { if #available(iOS 14.0, *) { if !locationManager.monitoredRegions.isEmpty { Logger.geoservices.info("Stop monitoring for all regions") @@ -99,7 +99,7 @@ internal class KlaviyoGeofenceManager { extension Geofence { /// Converts this geofence to a Core Location circular region /// - Returns: A CLCircularRegion instance - internal func toCLCircularRegion() -> CLCircularRegion { + func toCLCircularRegion() -> CLCircularRegion { let region = CLCircularRegion( center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), radius: radius, @@ -110,11 +110,11 @@ extension Geofence { } extension CLCircularRegion { - internal func toKlaviyoGeofence() throws -> Geofence { + func toKlaviyoGeofence() throws -> Geofence { try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius) } - internal var klaviyoLocationId: String? { + var klaviyoLocationId: String? { do { return try toKlaviyoGeofence().locationId } catch { diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index f03a569a..f91fcb5f 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -12,13 +12,13 @@ import KlaviyoCore import KlaviyoSwift import OSLog -public class KlaviyoLocationManager: NSObject { - internal static let shared = KlaviyoLocationManager() +class KlaviyoLocationManager: NSObject { + static let shared = KlaviyoLocationManager() private var locationManager: LocationManagerProtocol private let geofenceManager: KlaviyoGeofenceManager - internal init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { + init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) { self.locationManager = locationManager ?? CLLocationManager() self.geofenceManager = geofenceManager ?? KlaviyoGeofenceManager(locationManager: self.locationManager) @@ -36,14 +36,14 @@ public class KlaviyoLocationManager: NSObject { } @MainActor - internal func setupGeofencing() { + func setupGeofencing() { if environment.getLocationAuthorizationStatus() == .authorizedAlways { geofenceManager.setupGeofencing() } } @MainActor - internal func destroyGeofencing() { + func destroyGeofencing() { geofenceManager.destroyGeofencing() } } diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index 6edf0027..16b51e90 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -11,26 +11,26 @@ import KlaviyoCore import KlaviyoSwift /// Represents a Klaviyo geofence -internal struct Geofence: Equatable, Hashable, Codable { +struct Geofence: Equatable, Hashable, Codable { /// The geofence ID is a combination of the company ID and location ID from Klaviyo, separated by a colon. - internal let id: String + let id: String /// Longitude of the geofence center - internal let longitude: Double + let longitude: Double /// Latitude of the geofence center - internal let latitude: Double + let latitude: Double /// Radius of the geofence in meters - internal let radius: Double + let radius: Double /// Company ID to which this geofence belongs, extracted from the geofence ID. - internal var companyId: String { + var companyId: String { id.split(separator: ":").first.map(String.init) ?? "" } /// Location UUID to which this geofence belongs, extracted from the geofence ID. - internal var locationId: String { + var locationId: String { let components = id.split(separator: ":", maxSplits: 1) return components.count > 1 ? String(components[1]) : "" } @@ -42,7 +42,7 @@ internal struct Geofence: Equatable, Hashable, Codable { /// - latitude: Latitude coordinate of the geofence center /// - radius: Radius of the geofence in meters /// - Throws: `GeofenceError.invalidIdFormat` if the ID doesn't match the expected format - internal init( + init( id: String, longitude: Double, latitude: Double, @@ -68,6 +68,6 @@ internal struct Geofence: Equatable, Hashable, Codable { } /// Errors that can occur when working with geofences -internal enum GeofenceError: Error { +enum GeofenceError: Error { case invalidIdFormat(String) } diff --git a/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift b/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift index 72c86dde..5268fdae 100644 --- a/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift +++ b/Sources/KlaviyoLocation/Utilities/CLAuthorizationStatus+Ext.swift @@ -8,7 +8,7 @@ import CoreLocation extension CLAuthorizationStatus { - internal var description: String { + var description: String { switch self { case .notDetermined: "not determined" diff --git a/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift b/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift index 82ecbefd..c499b714 100644 --- a/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift +++ b/Sources/KlaviyoLocation/Utilities/CLLocationManager+LocationManagerProtocol.swift @@ -9,7 +9,7 @@ import CoreLocation // MARK: - Location Manager Protocol -internal protocol LocationManagerProtocol { +protocol LocationManagerProtocol { var delegate: CLLocationManagerDelegate? { get set } var allowsBackgroundLocationUpdates: Bool { get set } var currentAuthorizationStatus: CLAuthorizationStatus { get } diff --git a/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift b/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift index a967e2a0..e82004aa 100644 --- a/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift +++ b/Sources/KlaviyoLocation/Utilities/Logger+Ext.swift @@ -12,5 +12,5 @@ extension Logger { private static var subsystem = Bundle.main.bundleIdentifier ?? "" /// Logs events related to location services. - internal static let geoservices = Logger(subsystem: subsystem, category: "geoservices") + static let geoservices = Logger(subsystem: subsystem, category: "geoservices") } From 6da4b75f676f3ede829748fd2bc502f42fb0dc47 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Fri, 7 Nov 2025 10:11:39 -0500 Subject: [PATCH 21/21] Add query to endpoint test --- Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift b/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift index cadb1926..b51770ab 100644 --- a/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift +++ b/Tests/KlaviyoCoreTests/KlaviyoEndpointTests.swift @@ -188,5 +188,6 @@ final class KlaviyoEndpointTests: XCTestCase { // Then XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.path, "/client/geofences") + XCTAssertEqual(request.url?.query, "company_id=test_api_key") } }