Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/KlaviyoLocation/GeofenceService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ struct GeofenceService: GeofenceServiceProvider {
let response = try JSONDecoder().decode(GeofenceJSONResponse.self, from: data)
return try Set(response.data.map { rawGeofence in
try Geofence(
id: "_k:\(companyId):\(rawGeofence.id)",
id: "_k:\(companyId):\(rawGeofence.id):\(rawGeofence.attributes.duration.map { String($0) } ?? "")",
longitude: rawGeofence.attributes.longitude,
latitude: rawGeofence.attributes.latitude,
radius: rawGeofence.attributes.radius
Expand All @@ -72,5 +72,6 @@ private struct GeofenceJSON: Codable {
let latitude: Double
let longitude: Double
let radius: Double
let duration: Int?
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,24 +48,34 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate {
Task {
await stopGeofenceMonitoring()
}
@unknown default:
return
}
}

// MARK: Geofencing

public func locationManager(_ manager: CLLocationManager, didEnterRegion region: CLRegion) {
handleGeofenceEvent(region: region, eventType: .geofenceEnter)
Task { @MainActor in
await handleGeofenceEvent(region: region, eventType: .geofenceEnter)
}
}

public func locationManager(_ manager: CLLocationManager, didExitRegion region: CLRegion) {
handleGeofenceEvent(region: region, eventType: .geofenceExit)
Task { @MainActor in
await handleGeofenceEvent(region: region, eventType: .geofenceExit)
}
}

private func handleGeofenceEvent(region: CLRegion, eventType: Event.EventName.LocationEvent) {
@MainActor
private func handleGeofenceEvent(region: CLRegion, eventType: Event.EventName.LocationEvent) async {
checkForExpiredDwellTimers()
guard let region = region as? CLCircularRegion,
let klaviyoLocationId = region.klaviyoLocationId else {
let klaviyoGeofence = try? region.toKlaviyoGeofence(),
!klaviyoGeofence.companyId.isEmpty else {
return
}
let klaviyoLocationId = klaviyoGeofence.locationId

// Check cooldown period before processing event
guard cooldownTracker.isAllowed(geofenceId: klaviyoLocationId, transition: eventType) else {
Expand All @@ -87,9 +97,106 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate {
properties: ["$geofence_id": klaviyoLocationId]
)

await KlaviyoInternal.createGeofenceEvent(event: event, for: klaviyoGeofence.companyId)
if eventType == .geofenceEnter {
startDwellTimer(for: klaviyoLocationId, companyId: klaviyoGeofence.companyId)
} else {
cancelDwellTimer(for: klaviyoLocationId)
}
}
}

// MARK: Dwell Timer Management

extension KlaviyoLocationManager {
private func startDwellTimer(for klaviyoLocationId: String, companyId: String) {
cancelDwellTimer(for: klaviyoLocationId)
guard let dwellSeconds = activeGeofenceDurations[klaviyoLocationId] else {
return
}

let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(dwellSeconds), repeats: false) { [weak self] _ in
self?.handleDwellTimerFired(for: klaviyoLocationId, apiKey: companyId)
}
currentDwellTimers[klaviyoLocationId] = timer

// Persist timer start time, duration, and company ID for recovery if app terminates
dwellTimerTracker.saveTimer(geofenceId: klaviyoLocationId, startTime: environment.date().timeIntervalSince1970, duration: dwellSeconds, companyId: companyId)

if #available(iOS 14.0, *) {
Logger.geoservices.info("🕐 Started dwell timer for region \(klaviyoLocationId) with \(dwellSeconds) seconds")
}
}

private func cancelDwellTimer(for klaviyoLocationId: String) {
// remove tracking it from the persisted tracker
dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId)

if let timer = currentDwellTimers[klaviyoLocationId] {
timer.invalidate()
currentDwellTimers.removeValue(forKey: klaviyoLocationId)

if #available(iOS 14.0, *) {
Logger.geoservices.info("🕐 Cancelled dwell timer for region \(klaviyoLocationId)")
}
}
}

private func handleDwellTimerFired(for klaviyoLocationId: String, apiKey: String) {
// remove tracking in both memory and persisted tracker since it fired
currentDwellTimers.removeValue(forKey: klaviyoLocationId)
dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId)

guard let dwellDuration = activeGeofenceDurations[klaviyoLocationId] else {
return
}

let dwellEvent = Event(
name: .locationEvent(.geofenceDwell),
properties: [
"$geofence_id": klaviyoLocationId,
"$geofence_dwell_duration": dwellDuration
]
)

Task {
await MainActor.run {
KlaviyoInternal.create(event: event)
await KlaviyoInternal.createGeofenceEvent(event: dwellEvent, for: apiKey)
}

if #available(iOS 14.0, *) {
Logger.geoservices.info("🕐 Dwell event fired for region \(klaviyoLocationId)")
}
}

/// Check for expired timers and fire dwell events for them
/// Called on app launch/foreground as a best-effort recovery mechanism
@MainActor
@objc
func checkForExpiredDwellTimers() {
let expiredTimers = dwellTimerTracker.getExpiredTimers()

// Fire dwell events for expired timers
for (geofenceId, duration, companyId) in expiredTimers {
// Invalidate any corresponding in-memory timer to prevent duplicate events
if let timer = currentDwellTimers[geofenceId] {
timer.invalidate()
currentDwellTimers.removeValue(forKey: geofenceId)
}

let dwellEvent = Event(
name: .locationEvent(.geofenceDwell),
properties: [
"$geofence_id": geofenceId,
"$geofence_dwell_duration": duration
]
)

Task {
await KlaviyoInternal.createGeofenceEvent(event: dwellEvent, for: companyId)
}

if #available(iOS 14.0, *) {
Logger.geoservices.info("🕐 Fired expired dwell event for region \(geofenceId) (expired while app was terminated)")
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions Sources/KlaviyoLocation/KlaviyoLocationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class KlaviyoLocationManager: NSObject {
private var apiKeyCancellable: AnyCancellable?
private var lifecycleCancellable: AnyCancellable?
internal let cooldownTracker = GeofenceCooldownTracker()
internal let dwellTimerTracker = DwellTimerTracker()

var activeGeofenceDurations: [String: Int] = [:]
var currentDwellTimers: [String: Timer] = [:]

init(locationManager: LocationManagerProtocol? = nil) {
self.locationManager = locationManager ?? CLLocationManager()
Expand Down Expand Up @@ -69,6 +73,7 @@ class KlaviyoLocationManager: NSObject {
let geofencesToAdd = remoteGeofences.subtracting(activeGeofences)

await MainActor.run {
updateDwellSettings(remoteGeofences)
for geofence in geofencesToAdd {
locationManager.startMonitoring(for: geofence.toCLCircularRegion())
}
Expand Down Expand Up @@ -114,6 +119,12 @@ class KlaviyoLocationManager: NSObject {
}

klaviyoRegions.forEach(locationManager.stopMonitoring)
activeGeofenceDurations.removeAll()
for timer in currentDwellTimers.values {
timer.invalidate()
}
currentDwellTimers.removeAll()
dwellTimerTracker.clearAllTimers()
}

// MARK: - API Key Observation
Expand Down Expand Up @@ -160,6 +171,9 @@ class KlaviyoLocationManager: NSObject {
self.locationManager.startMonitoringSignificantLocationChanges()
case .foregrounded, .backgrounded:
self.locationManager.stopMonitoringSignificantLocationChanges()
Task { @MainActor in
self.checkForExpiredDwellTimers()
}
default:
break
}
Expand All @@ -170,4 +184,13 @@ class KlaviyoLocationManager: NSObject {
lifecycleCancellable?.cancel()
lifecycleCancellable = nil
}

// MARK: - Dwell Settings Management

private func updateDwellSettings(_ geofences: Set<Geofence>) {
activeGeofenceDurations.removeAll()
for geofence in geofences {
activeGeofenceDurations[geofence.locationId] = geofence.duration
}
}
}
30 changes: 16 additions & 14 deletions Sources/KlaviyoLocation/Models/Geofence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import KlaviyoSwift

/// Represents a Klaviyo geofence
struct Geofence: Equatable, Hashable, Codable {
/// The geofence ID in the format "_k:{companyId}:{UUID}" with a "_k" prefix, company ID, and location ID from Klaviyo, separated by colons.
/// The geofence ID in the format "_k:{companyId}:{UUID}:{duration}" with a "_k" prefix, company ID, location ID, and optional duration from Klaviyo, separated by colons.
let id: String

/// Longitude of the geofence center
Expand All @@ -26,21 +26,31 @@ struct Geofence: Equatable, Hashable, Codable {

/// Company ID to which this geofence belongs, extracted from the geofence ID.
var companyId: String {
let components = id.split(separator: ":")
guard components.count == 3, components[0] == "_k" else { return "" }
let components = id.split(separator: ":", omittingEmptySubsequences: false)
guard components.count == 4, components[0] == "_k" else { return "" }
return String(components[1])
}

/// Location UUID to which this geofence belongs, extracted from the geofence ID.
var locationId: String {
let components = id.split(separator: ":", maxSplits: 2)
guard components.count == 3, components[0] == "_k" else { return "" }
let components = id.split(separator: ":", omittingEmptySubsequences: false)
guard components.count == 4, components[0] == "_k" else { return "" }
return String(components[2])
}

/// Optional duration for this geofence to record a dwell event
var duration: Int? {
let components = id.split(separator: ":", omittingEmptySubsequences: false)
guard components.count == 4, components[0] == "_k" else { return nil }
Copy link
Contributor

Choose a reason for hiding this comment

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

didn't we have an isKlaviyo... method somewhere to check if something starts with _k (and therefore is a Klaviyo event/notification)?

let durationString = String(components[3])
// Return nil if duration component is empty
guard !durationString.isEmpty else { return nil }
return Int(durationString)
}

/// Creates a new geofence
/// - Parameters:
/// - id: Unique identifier for the geofence in format "_k:{companyId}:{UUID}" where companyId is 6 alphanumeric characters
/// - id: Unique identifier for the geofence in format "_k:{companyId}:{UUID}:{duration}" where duration is optional
/// - longitude: Longitude coordinate of the geofence center
/// - latitude: Latitude coordinate of the geofence center
/// - radius: Radius of the geofence in meters
Expand Down Expand Up @@ -82,12 +92,4 @@ extension CLCircularRegion {
func toKlaviyoGeofence() throws -> Geofence {
try Geofence(id: identifier, longitude: center.longitude, latitude: center.latitude, radius: radius)
}

var klaviyoLocationId: String? {
do {
return try toKlaviyoGeofence().locationId
} catch {
return nil
}
}
}
99 changes: 99 additions & 0 deletions Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// DwellTimerTracker.swift
// klaviyo-swift-sdk
//
// Created by Isobelle Lim on 1/27/25.
//

import Foundation
import KlaviyoCore
import KlaviyoSwift
import OSLog

/// Manages geofence dwell timer persistence and recovery.
///
/// This tracker handles persistence of dwell timer data to UserDefaults,
/// allowing recovery of expired timers when the app terminates and relaunches.
/// The actual Timer objects are managed by KlaviyoLocationManager.
class DwellTimerTracker {
private static let dwellTimersKey = "klaviyo_dwell_timers"

private struct DwellTimerData: Codable {
let startTime: TimeInterval
let duration: Int
let companyId: String
}

/// Save dwell timer data to UserDefaults
///
/// - Parameters:
/// - geofenceId: The geofence location ID
/// - startTime: The timestamp when the timer started
/// - duration: The duration of the timer in seconds
/// - companyId: The company ID associated with this geofence
func saveTimer(geofenceId: String, startTime: TimeInterval, duration: Int, companyId: String) {
var timerMap = loadTimers()
timerMap[geofenceId] = DwellTimerData(startTime: startTime, duration: duration, companyId: companyId)

guard let data = try? JSONEncoder().encode(timerMap) else {
return
}
UserDefaults.standard.set(data, forKey: Self.dwellTimersKey)
}

/// Remove dwell timer data from UserDefaults
///
/// - Parameter geofenceId: The geofence location ID
func removeTimer(geofenceId: String) {
var timerMap = loadTimers()
timerMap.removeValue(forKey: geofenceId)

guard let data = try? JSONEncoder().encode(timerMap) else {
return
}
UserDefaults.standard.set(data, forKey: Self.dwellTimersKey)
}

/// Clear all persisted dwell timer data from UserDefaults
/// Called when geofence monitoring is stopped to prevent stale events
func clearAllTimers() {
UserDefaults.standard.removeObject(forKey: Self.dwellTimersKey)
}

/// Load all persisted dwell timers from UserDefaults
///
/// - Returns: Dictionary mapping geofence IDs to their timer data
private func loadTimers() -> [String: DwellTimerData] {
guard let data = UserDefaults.standard.data(forKey: Self.dwellTimersKey),
let timerMap = try? JSONDecoder().decode([String: DwellTimerData].self, from: data) else {
return [:]
}
return timerMap
}

/// Check for expired timers, remove them from persistence, and return them
///
/// - Returns: Array of expired timer information (geofence ID, duration, and company ID)
func getExpiredTimers() -> [(geofenceId: String, duration: Int, companyId: String)] {
let timerMap = loadTimers()
guard !timerMap.isEmpty else { return [] }

let currentTime = environment.date().timeIntervalSince1970
var expiredTimers: [(geofenceId: String, duration: Int, companyId: String)] = []

for (geofenceId, timerData) in timerMap {
// Check if timer expired (elapsed >= duration)
if currentTime - timerData.startTime >= TimeInterval(timerData.duration) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can you create a private helper function (or computed property) to abstract this logic and make it more readable? Something like:

if timerData.isExpired {
    ...
}

expiredTimers.append((geofenceId: geofenceId, duration: timerData.duration, companyId: timerData.companyId))
// Remove expired timer from persistence
removeTimer(geofenceId: geofenceId)

if #available(iOS 14.0, *) {
Logger.geoservices.info("🕐 Found expired dwell timer for region \(geofenceId) (expired while app was terminated)")
}
}
}

return expiredTimers
}
}
Loading
Loading