Skip to content

Commit b5d674c

Browse files
belleklaviyoab1470ajaysubra
authored
Create Geofence events (#437)
* Use expected metric and dimension keys for geofence event * Fix test util * Add optional dwell field on Geofence object * Add default of 5 minutes * Implement dwell events * Simplify by unwrapping klaviyoLocationId at top level and use that in dwell timers * Remove unnecessary dwellEnterTimes * Don't actually change the apiUrl on the environment to mock fetch * Make dwell optional with default 5 min * Immediately flush queue for geofence events * Add helper in KlaviyoSDK+Location to ensure background support * 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 <[email protected]> --------- Co-authored-by: Andrew Balmer <[email protected]> * Remove dwell (for now) * Clean up warnings, helper functions, PR feedback * startMonitoringSignificantLocationChanges should support terminated events * Add initialized check to fetchGeofences to protect initial launches * OSLog privacy changes * Remove mock data * Remove locationManagerDelegate, reorganize, simplify background support * 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. * Add query to endpoint test --------- Co-authored-by: Andrew Balmer <[email protected]> Co-authored-by: Ajay Subramanya <[email protected]>
1 parent bed44a6 commit b5d674c

File tree

18 files changed

+141
-118
lines changed

18 files changed

+141
-118
lines changed

Examples/KlaviyoSwiftExamples/SPMExample/SPMExample/Info.plist

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
<key>UIBackgroundModes</key>
2727
<array>
2828
<string>remote-notification</string>
29+
<string>location</string>
30+
<string>fetch</string>
2931
</array>
3032
</dict>
3133
</plist>

Sources/KlaviyoCore/Networking/KlaviyoEndpoint.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public enum KlaviyoEndpoint: Equatable, Codable {
1717
case aggregateEvent(_ apiKey: String, _ payload: AggregateEventPayload)
1818
case resolveDestinationURL(trackingLink: URL, profileInfo: ProfilePayload)
1919
case logTrackingLinkClicked(trackingLink: URL, clickTime: Date, profileInfo: ProfilePayload)
20-
case fetchGeofences
20+
case fetchGeofences(_ apiKey: String)
2121

2222
private enum HeaderKey {
2323
static let profileInfo = "X-Klaviyo-Profile-Info"
@@ -50,9 +50,10 @@ public enum KlaviyoEndpoint: Equatable, Codable {
5050
let .createEvent(apiKey, _),
5151
let .registerPushToken(apiKey, _),
5252
let .unregisterPushToken(apiKey, _),
53-
let .aggregateEvent(apiKey, _):
53+
let .aggregateEvent(apiKey, _),
54+
let .fetchGeofences(apiKey):
5455
return [URLQueryItem(name: "company_id", value: apiKey)]
55-
case .resolveDestinationURL, .logTrackingLinkClicked, .fetchGeofences:
56+
case .resolveDestinationURL, .logTrackingLinkClicked:
5657
return []
5758
}
5859
}
@@ -114,7 +115,7 @@ public enum KlaviyoEndpoint: Equatable, Codable {
114115
case let .resolveDestinationURL(trackingLink, _), let .logTrackingLinkClicked(trackingLink, _, _):
115116
return trackingLink.path
116117
case .fetchGeofences:
117-
return "/geofences"
118+
return "/client/geofences"
118119
}
119120
}
120121

Sources/KlaviyoCore/Networking/NetworkSession.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public struct NetworkSession {
2828
self.data = data
2929
}
3030

31-
fileprivate static let currentApiRevision = "2025-07-15"
31+
fileprivate static let currentApiRevision = "2025-10-15.pre"
3232
fileprivate static let applicationJson = "application/json"
3333
fileprivate static let acceptedEncodings = ["br", "gzip", "deflate"]
3434
fileprivate static let mobileHeader = "1"

Sources/KlaviyoLocation/Assets/Geofences.json

Lines changed: 0 additions & 31 deletions
This file was deleted.

Sources/KlaviyoLocation/GeofenceService.swift

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import KlaviyoCore
1010
import KlaviyoSwift
1111
import OSLog
1212

13-
internal protocol GeofenceServiceProvider {
13+
protocol GeofenceServiceProvider {
1414
func fetchGeofences() async -> Set<Geofence>
1515
}
1616

17-
internal struct GeofenceService: GeofenceServiceProvider {
18-
internal func fetchGeofences() async -> Set<Geofence> {
17+
struct GeofenceService: GeofenceServiceProvider {
18+
func fetchGeofences() async -> Set<Geofence> {
1919
do {
2020
let data = try await fetchGeofenceData()
2121
return try await parseGeofences(from: data)
@@ -29,36 +29,28 @@ internal struct GeofenceService: GeofenceServiceProvider {
2929

3030
/// Fetches raw geofence data from the API
3131
private func fetchGeofenceData() async throws -> Data {
32-
// FIXME: Temporarily override the environment's API URL for this mock request
33-
let originalAPIURL = environment.apiURL
34-
environment.apiURL = {
35-
var components = URLComponents()
36-
components.scheme = "https"
37-
components.host = "mock-api.com"
38-
return components
39-
}
40-
41-
let endpoint = KlaviyoEndpoint.fetchGeofences
32+
let apiKey = try await KlaviyoInternal.fetchAPIKey()
33+
let endpoint = KlaviyoEndpoint.fetchGeofences(apiKey)
4234
let klaviyoRequest = KlaviyoRequest(endpoint: endpoint)
4335
let attemptInfo = try RequestAttemptInfo(attemptNumber: 1, maxAttempts: 1)
4436
let result = await environment.klaviyoAPI.send(klaviyoRequest, attemptInfo)
4537

46-
// FIXME: Restore the original API URL
47-
environment.apiURL = originalAPIURL
48-
4938
switch result {
5039
case let .success(data):
40+
if #available(iOS 14.0, *) {
41+
Logger.geoservices.info("Successfully fetched geofences")
42+
}
5143
return data
5244
case let .failure(error):
5345
if #available(iOS 14.0, *) {
54-
Logger.geoservices.error("Failed to fetch geofences from mock endpoint https://mock-api.com/geofences")
46+
Logger.geoservices.error("Failed to fetch geofences; error: \(error, privacy: .public)")
5547
}
5648
throw error
5749
}
5850
}
5951

6052
/// Parses raw geofence data and transforms it into Geofence objects with the companyId prepended to the id
61-
internal func parseGeofences(from data: Data) async throws -> Set<Geofence> {
53+
func parseGeofences(from data: Data) async throws -> Set<Geofence> {
6254
do {
6355
let response = try JSONDecoder().decode(GeofenceJSONResponse.self, from: data)
6456
let companyId = try await KlaviyoInternal.fetchAPIKey()

Sources/KlaviyoLocation/KlaviyoGeofenceManager.swift

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,17 @@
77

88
import CoreLocation
99
import KlaviyoCore
10+
import KlaviyoSwift
1011
import OSLog
1112

12-
internal class KlaviyoGeofenceManager {
13+
class KlaviyoGeofenceManager {
1314
private let locationManager: LocationManagerProtocol
1415

15-
internal init(locationManager: LocationManagerProtocol) {
16+
init(locationManager: LocationManagerProtocol) {
1617
self.locationManager = locationManager
1718
}
1819

19-
internal func setupGeofencing() {
20+
func setupGeofencing() {
2021
guard CLLocationManager.isMonitoringAvailable(for: CLCircularRegion.self) else {
2122
if #available(iOS 14.0, *) {
2223
Logger.geoservices.warning("Geofencing is not supported on this device")
@@ -32,11 +33,18 @@ internal class KlaviyoGeofenceManager {
3233
}
3334

3435
Task {
36+
guard let _ = try? await KlaviyoInternal.fetchAPIKey() else {
37+
if #available(iOS 14.0, *) {
38+
Logger.geoservices.info("SDK is not initialized, skipping geofence refresh")
39+
}
40+
return
41+
}
42+
3543
await updateGeofences()
3644
}
3745
}
3846

39-
internal func destroyGeofencing() {
47+
func destroyGeofencing() {
4048
if #available(iOS 14.0, *) {
4149
if !locationManager.monitoredRegions.isEmpty {
4250
Logger.geoservices.info("Stop monitoring for all regions")
@@ -85,3 +93,32 @@ internal class KlaviyoGeofenceManager {
8593
}
8694
}
8795
}
96+
97+
// MARK: Data Type Conversions
98+
99+
extension Geofence {
100+
/// Converts this geofence to a Core Location circular region
101+
/// - Returns: A CLCircularRegion instance
102+
func toCLCircularRegion() -> CLCircularRegion {
103+
let region = CLCircularRegion(
104+
center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
105+
radius: radius,
106+
identifier: id
107+
)
108+
return region
109+
}
110+
}
111+
112+
extension CLCircularRegion {
113+
func toKlaviyoGeofence() throws -> Geofence {
114+
try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius)
115+
}
116+
117+
var klaviyoLocationId: String? {
118+
do {
119+
return try toKlaviyoGeofence().locationId
120+
} catch {
121+
return nil
122+
}
123+
}
124+
}

Sources/KlaviyoLocation/KlaviyoLocationManager.swift

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@ import KlaviyoCore
1212
import KlaviyoSwift
1313
import OSLog
1414

15-
public class KlaviyoLocationManager: NSObject {
15+
class KlaviyoLocationManager: NSObject {
1616
static let shared = KlaviyoLocationManager()
1717

1818
private var locationManager: LocationManagerProtocol
1919
private let geofenceManager: KlaviyoGeofenceManager
20-
private let geofencePublisher: PassthroughSubject<String, Never> = .init()
2120

22-
internal init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) {
21+
init(locationManager: LocationManagerProtocol? = nil, geofenceManager: KlaviyoGeofenceManager? = nil) {
2322
self.locationManager = locationManager ?? CLLocationManager()
2423
self.geofenceManager = geofenceManager ?? KlaviyoGeofenceManager(locationManager: self.locationManager)
2524

2625
super.init()
2726
self.locationManager.delegate = self
2827
self.locationManager.allowsBackgroundLocationUpdates = true
28+
self.locationManager.startMonitoringSignificantLocationChanges()
2929
}
3030

3131
deinit {
@@ -36,14 +36,14 @@ public class KlaviyoLocationManager: NSObject {
3636
}
3737

3838
@MainActor
39-
internal func setupGeofencing() {
39+
func setupGeofencing() {
4040
if environment.getLocationAuthorizationStatus() == .authorizedAlways {
4141
geofenceManager.setupGeofencing()
4242
}
4343
}
4444

4545
@MainActor
46-
internal func destroyGeofencing() {
46+
func destroyGeofencing() {
4747
geofenceManager.destroyGeofencing()
4848
}
4949
}
@@ -90,43 +90,53 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate {
9090
// MARK: Geofencing
9191

9292
public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
93-
guard let region = region as? CLCircularRegion else { return }
93+
guard let region = region as? CLCircularRegion,
94+
let klaviyoLocationId = region.klaviyoLocationId else {
95+
if #available(iOS 14.0, *) {
96+
Logger.geoservices.info("Received non-Klaviyo geofence notification. Skipping.")
97+
}
98+
return
99+
}
94100
if #available(iOS 14.0, *) {
95-
Logger.geoservices.info("🌎 User entered region \"\(region.identifier)\"")
101+
Logger.geoservices.info("🌎 User entered region \"\(klaviyoLocationId, privacy: .public)\"")
96102
}
97103

98104
let enterEvent = Event(
99-
name: .locationEvent(.enteredBoundary),
105+
name: .locationEvent(.geofenceEnter),
100106
properties: [
101-
"boundaryIdentifier": region.identifier
107+
"geofence_id": klaviyoLocationId
102108
]
103109
)
104110

105111
Task {
106112
await MainActor.run {
107113
KlaviyoInternal.create(event: enterEvent)
108-
geofencePublisher.send("Entered \(region.identifier)")
109114
}
110115
}
111116
}
112117

113118
public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
114-
guard let region = region as? CLCircularRegion else { return }
119+
guard let region = region as? CLCircularRegion,
120+
let klaviyoLocationId = region.klaviyoLocationId else {
121+
if #available(iOS 14.0, *) {
122+
Logger.geoservices.warning("Received non-Klaviyo geofence notification. Skipping.")
123+
}
124+
return
125+
}
115126
if #available(iOS 14.0, *) {
116-
Logger.geoservices.info("🌎 User exited region \"\(region.identifier)\"")
127+
Logger.geoservices.info("🌎 User exited region \"\(klaviyoLocationId, privacy: .public)\"")
117128
}
118129

119130
let exitEvent = Event(
120-
name: .locationEvent(.exitedBoundary),
131+
name: .locationEvent(.geofenceExit),
121132
properties: [
122-
"boundaryIdentifier": region.identifier
133+
"geofence_id": klaviyoLocationId
123134
]
124135
)
125136

126137
Task {
127138
await MainActor.run {
128139
KlaviyoInternal.create(event: exitEvent)
129-
geofencePublisher.send("Exited \(region.identifier)")
130140
}
131141
}
132142
}

Sources/KlaviyoLocation/KlaviyoSDK+Location.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created by Isobelle Lim on 8/27/25.
66
//
77

8+
import CoreLocation
89
import Foundation
910
import KlaviyoSwift
1011

@@ -20,6 +21,11 @@ extension KlaviyoSDK {
2021
}
2122
}
2223

24+
/// To be called in didFinishLaunchingWithOptions to ensure geofence events that happen in a backgrounded/terminated state are processed.
25+
public func monitorGeofencesFromBackground() {
26+
_ = KlaviyoLocationManager.shared
27+
}
28+
2329
/// Unregisters app for geofencing. Stops monitoring for geofences and cleans up resources.
2430
@MainActor
2531
public func unregisterGeofencing() {

0 commit comments

Comments
 (0)