From 715ec053f87b06d3cf66cfac3f25e3f9ff979e79 Mon Sep 17 00:00:00 2001 From: Belle Lim Date: Tue, 25 Nov 2025 16:09:46 -0500 Subject: [PATCH 01/15] Initialize on geofence event (#460) * Initialize on geofence event * Add tests to check priority insertion, instant flush, and init effect * guard against empty companyIds form malformed geofences * Reject geofence events if they have a mismatching API key to the one in store --- ...ionManager+CLLocationManagerDelegate.swift | 23 +-- Sources/KlaviyoLocation/Models/Geofence.swift | 8 -- Sources/KlaviyoSwift/KlaviyoInternal.swift | 28 ++++ .../KlaviyoSwift/Utilities/EventBuffer.swift | 9 ++ .../KlaviyoSwiftTests/EventBufferTests.swift | 13 -- .../KlaviyoInternalTests.swift | 132 ++++++++++++++++++ .../StateManagementTests.swift | 62 ++++++++ 7 files changed, 245 insertions(+), 30 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index aec5e05a..b593ea38 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -48,24 +48,33 @@ 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 { 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 { @@ -87,10 +96,6 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { properties: ["$geofence_id": klaviyoLocationId] ) - Task { - await MainActor.run { - KlaviyoInternal.create(event: event) - } - } + await KlaviyoInternal.createGeofenceEvent(event: event, for: klaviyoGeofence.companyId) } } diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index e363b39e..0fb5f6ef 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -82,12 +82,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 - } - } } diff --git a/Sources/KlaviyoSwift/KlaviyoInternal.swift b/Sources/KlaviyoSwift/KlaviyoInternal.swift index 59fc8dcb..b2712f8e 100644 --- a/Sources/KlaviyoSwift/KlaviyoInternal.swift +++ b/Sources/KlaviyoSwift/KlaviyoInternal.swift @@ -189,6 +189,12 @@ package enum KlaviyoInternal { profileEventCancellable = nil } + /// Clears the event buffer to ensure clean state between tests. + /// This prevents events from previous tests from being replayed in new tests. + package static func clearEventBuffer() { + eventBuffer.clear() + } + /// Enriches an event with metadata (device info, SDK info, etc.) /// - Parameter event: The event to enrich /// - Returns: A new Event with metadata appended to properties @@ -222,6 +228,28 @@ package enum KlaviyoInternal { dispatchOnMainThread(action: .enqueueEvent(event)) } + // MARK: - Geofence Event + + /// Send a geofence event to Klaviyo. + /// If the SDK is not yet initialized, it will automatically initialize using the API key extracted from the geofence. + /// If the SDK is already initialized with a different API key, the event will be ignored. + /// + /// - Parameters: + /// - apiKey: The API key (company ID) extracted from the geofence event + /// - event: The geofence event to be sent + @MainActor + package static func createGeofenceEvent(event: Event, for apiKey: String) async { + if let storedApiKey = try? await fetchAPIKey() { + guard storedApiKey == apiKey else { + return + } + dispatchOnMainThread(action: .enqueueEvent(event)) + } else { + dispatchOnMainThread(action: .initialize(apiKey)) + dispatchOnMainThread(action: .enqueueEvent(event)) + } + } + // MARK: - Deep link handling /// Handles a deep link according to the handler configured in `klaviyoSwiftEnvironment` diff --git a/Sources/KlaviyoSwift/Utilities/EventBuffer.swift b/Sources/KlaviyoSwift/Utilities/EventBuffer.swift index 5de37248..ef48dc4f 100644 --- a/Sources/KlaviyoSwift/Utilities/EventBuffer.swift +++ b/Sources/KlaviyoSwift/Utilities/EventBuffer.swift @@ -93,4 +93,13 @@ final class EventBuffer { return recentEvents } } + + /// Clears all events from the buffer. + /// This is useful for testing to ensure clean state between tests. + func clear() { + queue.async(flags: .barrier) { [weak self] in + guard let self else { return } + self.buffer.removeAll() + } + } } diff --git a/Tests/KlaviyoSwiftTests/EventBufferTests.swift b/Tests/KlaviyoSwiftTests/EventBufferTests.swift index eb35eb5a..11674cad 100644 --- a/Tests/KlaviyoSwiftTests/EventBufferTests.swift +++ b/Tests/KlaviyoSwiftTests/EventBufferTests.swift @@ -5,7 +5,6 @@ // Created by Ajay Subramanya on 10/7/25. // -@testable import KlaviyoLocation @testable import KlaviyoSwift import Foundation import XCTest @@ -270,16 +269,4 @@ 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") - } } diff --git a/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift b/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift index fd55cf76..3233f55f 100644 --- a/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift +++ b/Tests/KlaviyoSwiftTests/KlaviyoInternalTests.swift @@ -24,6 +24,8 @@ final class KlaviyoInternalTests: XCTestCase { cancellables.removeAll() KlaviyoInternal.resetAPIKeySubject() KlaviyoInternal.resetProfileDataSubject() + KlaviyoInternal.resetEventSubject() + KlaviyoInternal.clearEventBuffer() } // MARK: - Profile Data Tests @@ -709,4 +711,134 @@ final class KlaviyoInternalTests: XCTestCase { XCTAssertNotNil(properties["Device ID"], "Buffered event should include metadata") XCTAssertNotNil(properties["SDK Name"], "Buffered event should include metadata") } + + // MARK: - Geofence Event Tests + + @MainActor + func testCreateGeofenceEvent_initializesSDKAndSendsEventWhenUninitialized() async throws { + // Given: SDK is uninitialized + let initialState = KlaviyoState(queue: [], initalizationState: .uninitialized) + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create geofence event + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let apiKey = "TEST123" + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: apiKey) + try await Task.sleep(nanoseconds: 100_000_000) + + // Then: Verify SDK is initialized and event is in pending requests + let currentState = testStore.state.value + XCTAssertEqual(currentState.initalizationState, .initialized, "SDK should be initialized") + XCTAssertEqual(currentState.apiKey, apiKey, "API key should be set") + } + + @MainActor + func testCreateGeofenceEvent_flushesQueueWhenQueueHasItems() async throws { + // Given: SDK is initialized with items in the queue + var initialState = INITIALIZED_TEST_STATE() + initialState.flushing = false + + // Add some existing requests to the queue + let existingRequest1 = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) + let existingRequest2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "token1", enablement: .authorized) + initialState.queue = [existingRequest1, existingRequest2] + + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create a geofence event with matching API key + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let apiKey = initialState.apiKey! + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: apiKey) + try await Task.sleep(nanoseconds: 500_000_000) + + // Then: Verify queue is flushed (items moved to requestsInFlight and queue is empty) + XCTAssertTrue(testStore.state.value.queue.isEmpty, "Queue should be empty after a geofence event forces a flush") + } + + @MainActor + func testCreateGeofenceEvent_ignoresEventWhenAPIKeyDoesNotMatch() async throws { + // Given: SDK is initialized with a different API key + var initialState = INITIALIZED_TEST_STATE() + initialState.apiKey = "EXISTING_KEY" + initialState.flushing = false + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create a geofence event with a different API key + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let differentApiKey = "DIFFERENT_KEY" + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: differentApiKey) + try await Task.sleep(nanoseconds: 100_000_000) + + // Then: Verify SDK was not re-initialized and event was not enqueued + let currentState = testStore.state.value + XCTAssertEqual(currentState.apiKey, "EXISTING_KEY", "API key should remain unchanged") + XCTAssertEqual(currentState.pendingRequests.count, 0, "Event should not be enqueued when API key doesn't match") + XCTAssertEqual(currentState.queue.count, 0, "Queue should remain empty") + } + + @MainActor + func testCreateGeofenceEvent_enqueuesEventWhenAPIKeyMatches() async throws { + // Given: SDK is initialized with matching API key + var initialState = INITIALIZED_TEST_STATE() + initialState.apiKey = "MATCHING_KEY" + initialState.flushing = false + + // Add some existing requests to the queue + let existingRequest1 = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) + let existingRequest2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "token1", enablement: .authorized) + initialState.queue = [existingRequest1, existingRequest2] + + let testStore = Store(initialState: initialState, reducer: KlaviyoReducer()) + klaviyoSwiftEnvironment.statePublisher = { testStore.state.eraseToAnyPublisher() } + klaviyoSwiftEnvironment.send = { action in + _ = testStore.send(action) + return nil + } + klaviyoSwiftEnvironment.state = { testStore.state.value } + klaviyoSwiftEnvironment.stateChangePublisher = { Empty().eraseToAnyPublisher() } + + // When: Create a geofence event with matching API key + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + let matchingApiKey = "MATCHING_KEY" + await KlaviyoInternal.createGeofenceEvent(event: geofenceEvent, for: matchingApiKey) + try await Task.sleep(nanoseconds: 200_000_000) + + // Then: Verify event was enqueued (should trigger flushQueue for prioritized events) + let currentState = testStore.state.value + XCTAssertEqual(currentState.apiKey, "MATCHING_KEY", "API key should remain unchanged") + XCTAssertTrue(currentState.queue.isEmpty || !currentState.requestsInFlight.isEmpty, "Event should be processed (either in queue or in flight)") + } } diff --git a/Tests/KlaviyoSwiftTests/StateManagementTests.swift b/Tests/KlaviyoSwiftTests/StateManagementTests.swift index e13873c3..8003d8ad 100644 --- a/Tests/KlaviyoSwiftTests/StateManagementTests.swift +++ b/Tests/KlaviyoSwiftTests/StateManagementTests.swift @@ -701,4 +701,66 @@ class StateManagementTests: XCTestCase { await store.receive(.setPushEnablement(PushEnablement.authorized), timeout: TIMEOUT_NANOSECONDS) await store.receive(.setBadgeCount(0)) } + + @MainActor + func testPrioritizedEventsAreInsertedAtFrontOfQueue() async throws { + var initialState = INITIALIZED_TEST_STATE() + initialState.flushing = false + + // Add some existing requests to the queue + let existingRequest1 = initialState.buildProfileRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!) + let existingRequest2 = initialState.buildTokenRequest(apiKey: initialState.apiKey!, anonymousId: initialState.anonymousId!, pushToken: "token1", enablement: .authorized) + initialState.queue = [existingRequest1, existingRequest2] + + let store = TestStore(initialState: initialState, reducer: KlaviyoReducer()) + + // Test geofence event is inserted at front + let geofenceEvent = Event( + name: .locationEvent(.geofenceEnter), + properties: ["$geofence_id": "test-location-id"] + ) + + var geofenceRequest: KlaviyoRequest? + await store.send(.enqueueEvent(geofenceEvent)) { + // Geofence event is prioritized, so it should be inserted at index 0 + geofenceRequest = try KlaviyoRequest( + endpoint: .createEvent( + XCTUnwrap($0.apiKey), + CreateEventPayload( + data: CreateEventPayload.Event( + name: geofenceEvent.metric.name.value, + properties: geofenceEvent.properties, + phoneNumber: $0.phoneNumber, + anonymousId: initialState.anonymousId!, + time: geofenceEvent.time, + pushToken: $0.pushTokenData?.pushToken + ) + ) + ) + ) + $0.queue.insert(geofenceRequest!, at: 0) + } + + var actualGeofenceRequest: KlaviyoRequest? + await store.receive(.flushQueue) { + $0.flushing = true + $0.requestsInFlight = $0.queue + $0.queue = [] + XCTAssertEqual($0.requestsInFlight.count, 3, "Should have 3 requests in flight") + actualGeofenceRequest = $0.requestsInFlight[0] + if case let .createEvent(_, payload) = actualGeofenceRequest!.endpoint { + XCTAssertEqual(payload.data.attributes.metric.data.attributes.name, "$geofence_enter", "First request in flight should be geofence event") + } else { + XCTFail("First request in flight should be geofence event") + } + XCTAssertEqual($0.requestsInFlight[1].id, existingRequest1.id, "Second request should be existing request 1") + XCTAssertEqual($0.requestsInFlight[2].id, existingRequest2.id, "Third request should be existing request 2") + } + await store.receive(.sendRequest) + await store.receive(.deQueueCompletedResults(actualGeofenceRequest!)) { + $0.requestsInFlight.removeAll { $0.id == actualGeofenceRequest!.id } + $0.retryState = .retry(1) + $0.flushing = false + } + } } From 2d37272499a16b2b418461c1f8fc3e523e926d24 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Wed, 26 Nov 2025 15:47:42 -0500 Subject: [PATCH 02/15] Add duration field to Geofence object --- Sources/KlaviyoLocation/GeofenceService.swift | 3 +- Sources/KlaviyoLocation/Models/Geofence.swift | 22 ++- .../KlaviyoLocationTests/GeofenceTests.swift | 147 +++++++++++++++++- 3 files changed, 159 insertions(+), 13 deletions(-) diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 37e88c4f..1eee8316 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -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 @@ -72,5 +72,6 @@ private struct GeofenceJSON: Codable { let latitude: Double let longitude: Double let radius: Double + let duration: Int? } } diff --git a/Sources/KlaviyoLocation/Models/Geofence.swift b/Sources/KlaviyoLocation/Models/Geofence.swift index 0fb5f6ef..f6812df4 100644 --- a/Sources/KlaviyoLocation/Models/Geofence.swift +++ b/Sources/KlaviyoLocation/Models/Geofence.swift @@ -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 @@ -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 } + 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 diff --git a/Tests/KlaviyoLocationTests/GeofenceTests.swift b/Tests/KlaviyoLocationTests/GeofenceTests.swift index 97499107..dcac23b7 100644 --- a/Tests/KlaviyoLocationTests/GeofenceTests.swift +++ b/Tests/KlaviyoLocationTests/GeofenceTests.swift @@ -17,25 +17,67 @@ final class GeofenceTests: XCTestCase { func testGeofenceInitialization() throws { let geofence = try Geofence( - id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:", + longitude: -122.03026995144546, + latitude: 37.33204742438631, + radius: 100.0 + ) + + XCTAssertEqual(geofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:") + XCTAssertEqual(geofence.longitude, -122.03026995144546) + XCTAssertEqual(geofence.latitude, 37.33204742438631) + XCTAssertEqual(geofence.radius, 100.0) + XCTAssertEqual(geofence.companyId, "ABC123") + XCTAssertEqual(geofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertNil(geofence.duration) + } + + func testGeofenceInitializationWithDuration() throws { + let geofence = try Geofence( + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:300", longitude: -122.03026995144546, latitude: 37.33204742438631, radius: 100.0 ) - XCTAssertEqual(geofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(geofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:300") XCTAssertEqual(geofence.longitude, -122.03026995144546) XCTAssertEqual(geofence.latitude, 37.33204742438631) XCTAssertEqual(geofence.radius, 100.0) XCTAssertEqual(geofence.companyId, "ABC123") XCTAssertEqual(geofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(geofence.duration, 300) + } + + func testGeofenceRequiresFourComponents() throws { + // Test that geofence IDs must have exactly 4 components (3 colons) + // IDs with only 3 components should result in empty companyId/locationId + let invalidGeofence = try Geofence( + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", + longitude: -122.03026995144546, + latitude: 37.33204742438631, + radius: 100.0 + ) + XCTAssertEqual(invalidGeofence.companyId, "", "Company ID should be empty for invalid format") + XCTAssertEqual(invalidGeofence.locationId, "", "Location ID should be empty for invalid format") + XCTAssertNil(invalidGeofence.duration, "Duration should be nil for invalid format") + + // Test that valid 4-component IDs work correctly + let validGeofence = try Geofence( + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:", + longitude: -122.03026995144546, + latitude: 37.33204742438631, + radius: 100.0 + ) + XCTAssertEqual(validGeofence.companyId, "ABC123", "Company ID should be extracted correctly") + XCTAssertEqual(validGeofence.locationId, "8db4effa-44f1-45e6-a88d-8e7d50516a0f", "Location ID should be extracted correctly") } // MARK: - Core Location Conversion Tests func testToCLCircularRegion() throws { let geofence = try Geofence( - id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f", + id: "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:", longitude: -122.03026995144546, latitude: 37.33204742438631, radius: 100.0 @@ -43,7 +85,7 @@ final class GeofenceTests: XCTestCase { let clRegion = geofence.toCLCircularRegion() - XCTAssertEqual(clRegion.identifier, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(clRegion.identifier, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:") XCTAssertEqual(clRegion.center.longitude, -122.03026995144546) XCTAssertEqual(clRegion.center.latitude, 37.33204742438631) XCTAssertEqual(clRegion.radius, 100.0) @@ -112,17 +154,110 @@ final class GeofenceTests: XCTestCase { // Then XCTAssertEqual(geofences.count, 2) let firstGeofence = geofences.first { $0.locationId == "8db4effa-44f1-45e6-a88d-8e7d50516a0f" }! - XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f") + XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:") XCTAssertEqual(firstGeofence.companyId, "ABC123") XCTAssertEqual(firstGeofence.latitude, 40.7128) XCTAssertEqual(firstGeofence.longitude, -74.006) XCTAssertEqual(firstGeofence.radius, 100) + XCTAssertNil(firstGeofence.duration, "Duration should be nil when not provided in JSON") let secondGeofence = geofences.first { $0.locationId == "a84011cf-93ef-4e78-b047-c0ce4ea258e4" }! - XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4") + XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4:") XCTAssertEqual(secondGeofence.companyId, "ABC123") XCTAssertEqual(secondGeofence.latitude, 40.6892) XCTAssertEqual(secondGeofence.longitude, -74.0445) XCTAssertEqual(secondGeofence.radius, 200) + XCTAssertNil(secondGeofence.duration, "Duration should be nil when not provided in JSON") + } + + func testGeofenceServiceParsesDurationFromJSON() async throws { + // Given + KlaviyoLocationTestUtils.setupTestEnvironment(apiKey: "ABC123") + let jsonString = """ + { + "data": [ + { + "type": "geofence", + "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", + "attributes": { + "latitude": 40.7128, + "longitude": -74.006, + "radius": 100, + "duration": 300 + } + }, + { + "type": "geofence", + "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", + "attributes": { + "latitude": 40.6892, + "longitude": -74.0445, + "radius": 200, + "duration": 60 + } + } + ] + } + """ + let testData = jsonString.data(using: .utf8)! + let geofenceService = GeofenceService() + + // When + let geofences = try await geofenceService.parseGeofences(from: testData, companyId: "ABC123") + + // Then + XCTAssertEqual(geofences.count, 2) + let firstGeofence = geofences.first { $0.locationId == "8db4effa-44f1-45e6-a88d-8e7d50516a0f" }! + XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:300") + XCTAssertEqual(firstGeofence.duration, 300) + + let secondGeofence = geofences.first { $0.locationId == "a84011cf-93ef-4e78-b047-c0ce4ea258e4" }! + XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4:60") + XCTAssertEqual(secondGeofence.duration, 60) + } + + func testGeofenceServiceHandlesMixedDurationValues() async throws { + // Given - one geofence with duration, one without + KlaviyoLocationTestUtils.setupTestEnvironment(apiKey: "ABC123") + let jsonString = """ + { + "data": [ + { + "type": "geofence", + "id": "8db4effa-44f1-45e6-a88d-8e7d50516a0f", + "attributes": { + "latitude": 40.7128, + "longitude": -74.006, + "radius": 100, + "duration": 120 + } + }, + { + "type": "geofence", + "id": "a84011cf-93ef-4e78-b047-c0ce4ea258e4", + "attributes": { + "latitude": 40.6892, + "longitude": -74.0445, + "radius": 200 + } + } + ] + } + """ + let testData = jsonString.data(using: .utf8)! + let geofenceService = GeofenceService() + + // When + let geofences = try await geofenceService.parseGeofences(from: testData, companyId: "ABC123") + + // Then + XCTAssertEqual(geofences.count, 2) + let firstGeofence = geofences.first { $0.locationId == "8db4effa-44f1-45e6-a88d-8e7d50516a0f" }! + XCTAssertEqual(firstGeofence.id, "_k:ABC123:8db4effa-44f1-45e6-a88d-8e7d50516a0f:120") + XCTAssertEqual(firstGeofence.duration, 120) + + let secondGeofence = geofences.first { $0.locationId == "a84011cf-93ef-4e78-b047-c0ce4ea258e4" }! + XCTAssertEqual(secondGeofence.id, "_k:ABC123:a84011cf-93ef-4e78-b047-c0ce4ea258e4:") + XCTAssertNil(secondGeofence.duration) } } From e771c52de6a3a4f24a424cb1dcbfb43d0f44cfd5 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Wed, 26 Nov 2025 16:29:27 -0500 Subject: [PATCH 03/15] Add dwell timers and dwell event firing --- ...ionManager+CLLocationManagerDelegate.swift | 68 +++++++++++++++++++ .../KlaviyoLocationManager.swift | 16 +++++ 2 files changed, 84 insertions(+) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index b593ea38..80a4daad 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -97,5 +97,73 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { ) await KlaviyoInternal.createGeofenceEvent(event: event, for: klaviyoGeofence.companyId) + if eventType == .geofenceEnter { + startDwellTimer(for: klaviyoLocationId) + } else { + cancelDwellTimer(for: klaviyoLocationId) + } + } +} + +extension KlaviyoLocationManager { + // MARK: Dwell Timer Management + + private func startDwellTimer(for klaviyoLocationId: String) { + cancelDwellTimer(for: klaviyoLocationId) + guard let dwellSeconds = geofenceDwellSettings[klaviyoLocationId] else { + if #available(iOS 14.0, *) { + Logger.geoservices.log("Dwell settings have not been specified for region \(klaviyoLocationId). Aborting dwell timer creation.") + } + 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)") + } + } else { + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Attempted to cancel dwell timer for region \(klaviyoLocationId, privacy: .public), but no dwell timer was found for that region") + } + } + } + + private func handleDwellTimerFired(for klaviyoLocationId: String) { + dwellTimers.removeValue(forKey: klaviyoLocationId) + guard let dwellDuration = geofenceDwellSettings[klaviyoLocationId] else { + return + } + + let dwellEvent = Event( + name: .locationEvent(.geofenceDwell), + properties: [ + "$geofence_id": klaviyoLocationId, + "$geofence_dwell_duration": dwellDuration + ] + ) + + Task { + await MainActor.run { + KlaviyoInternal.create(event: dwellEvent) + } + } + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Dwell event fired for region \(klaviyoLocationId)") + } } } diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index a2b37470..99b5482c 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -20,6 +20,9 @@ class KlaviyoLocationManager: NSObject { private var lifecycleCancellable: AnyCancellable? internal let cooldownTracker = GeofenceCooldownTracker() + var geofenceDwellSettings: [String: Int] = [:] + var dwellTimers: [String: Timer] = [:] + init(locationManager: LocationManagerProtocol? = nil) { self.locationManager = locationManager ?? CLLocationManager() @@ -69,6 +72,7 @@ class KlaviyoLocationManager: NSObject { let geofencesToAdd = remoteGeofences.subtracting(activeGeofences) await MainActor.run { + updateDwellSettings(remoteGeofences) for geofence in geofencesToAdd { locationManager.startMonitoring(for: geofence.toCLCircularRegion()) } @@ -114,6 +118,11 @@ class KlaviyoLocationManager: NSObject { } klaviyoRegions.forEach(locationManager.stopMonitoring) + for timer in dwellTimers.values { + timer.invalidate() + } + dwellTimers.removeAll() + geofenceDwellSettings.removeAll() } // MARK: - API Key Observation @@ -141,6 +150,13 @@ class KlaviyoLocationManager: NSObject { } } + private func updateDwellSettings(_ geofences: Set) { + geofenceDwellSettings.removeAll() + for geofence in geofences { + geofenceDwellSettings[geofence.locationId] = geofence.duration + } + } + private func stopObservingAPIKeyChanges() { apiKeyCancellable?.cancel() apiKeyCancellable = nil From d6c726d4b0f80e0fd9f20769efc2dd18cbbbffc9 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 11:45:33 -0500 Subject: [PATCH 04/15] Add mock stub (for now) to test duration --- Sources/KlaviyoLocation/GeofenceService.swift | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 1eee8316..4b4609ae 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -28,20 +28,64 @@ struct GeofenceService: GeofenceServiceProvider { } private func fetchGeofenceData(apiKey: String) async throws -> Data { - 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) + // MARK: - Mock JSON Response (Real fetch code commented out below) - switch result { - case let .success(data): - return data - case let .failure(error): - if #available(iOS 14.0, *) { - Logger.geoservices.error("Failed to fetch geofences; error: \(error, privacy: .public)") - } - throw error + let mockJSONString = """ + { + "data": [ + { + "type": "geofence", + "id": "geofence-1", + "attributes": { + "latitude": 37.7749, + "longitude": -122.4194, + "radius": 100.0, + "duration": 60 + } + }, + { + "type": "geofence", + "id": "geofence-2", + "attributes": { + "latitude": 40.7128, + "longitude": -74.0060, + "radius": 150.0, + "duration": 120 + } + }, + { + "type": "geofence", + "id": "geofence-3", + "attributes": { + "latitude": 34.0522, + "longitude": -118.2437, + "radius": 200.0, + "duration": null + } + } + ] } + """ + return mockJSONString.data(using: .utf8)! + + // MARK: - Real fetch code (commented out for testing) + + /* + 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) + + switch result { + case let .success(data): + return data + case let .failure(error): + if #available(iOS 14.0, *) { + Logger.geoservices.error("Failed to fetch geofences; error: \(error, privacy: .public)") + } + throw error + } + */ } func parseGeofences(from data: Data, companyId: String) throws -> Set { From 7fe48adbca604954cfcf03adfe66e43acc991fba Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 13:38:47 -0500 Subject: [PATCH 05/15] Create DwellTimerTracker for persistence --- .../Utilities/DwellTimerTracker.swift | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift diff --git a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift new file mode 100644 index 00000000..a3d8a42b --- /dev/null +++ b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift @@ -0,0 +1,105 @@ +// +// 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 + } + + /// 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 + func saveTimer(geofenceId: String, startTime: TimeInterval, duration: Int) { + var timerMap = loadTimers() + timerMap[geofenceId] = DwellTimerData(startTime: startTime, duration: duration) + + 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) + } + + /// 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 and return information about them + /// + /// - Parameter activeTimerIds: Set of geofence IDs that currently have active in-memory timers + /// - Returns: Array of expired timer information (geofence ID and duration) + func checkExpiredTimers(activeTimerIds: Set) -> [(geofenceId: String, duration: Int)] { + let timerMap = loadTimers() + guard !timerMap.isEmpty else { return [] } + + let currentTime = environment.date().timeIntervalSince1970 + var expiredTimers: [(geofenceId: String, duration: Int)] = [] + var timersToRemove: [String] = [] + + for (geofenceId, timerData) in timerMap { + // If timer exists in memory, it's already active - remove from persistence to avoid duplication + if activeTimerIds.contains(geofenceId) { + timersToRemove.append(geofenceId) + continue + } + + let age = currentTime - timerData.startTime + + // Check if timer expired (elapsed >= duration) + if age >= TimeInterval(timerData.duration) { + timersToRemove.append(geofenceId) + expiredTimers.append((geofenceId: geofenceId, duration: timerData.duration)) + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Found expired dwell timer for region \(geofenceId) (expired while app was terminated)") + } + } + } + + // Clean up all timers that need to be removed + for geofenceId in timersToRemove { + removeTimer(geofenceId: geofenceId) + } + + return expiredTimers + } +} From 1fba188ee27022132d55d2b34dca41e4a46ba9f5 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 13:40:43 -0500 Subject: [PATCH 06/15] Add checkExpiredDwellTimers func to fire dwell events --- ...ionManager+CLLocationManagerDelegate.swift | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index 80a4daad..17b7c44f 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -105,9 +105,9 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { } } -extension KlaviyoLocationManager { - // MARK: Dwell Timer Management +// MARK: Dwell Timer Management +extension KlaviyoLocationManager { private func startDwellTimer(for klaviyoLocationId: String) { cancelDwellTimer(for: klaviyoLocationId) guard let dwellSeconds = geofenceDwellSettings[klaviyoLocationId] else { @@ -166,4 +166,32 @@ extension KlaviyoLocationManager { 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 + func checkExpiredDwellTimers() { + let expiredTimers = dwellTimerTracker.checkExpiredTimers(activeTimerIds: Set(dwellTimers.keys)) + + // Fire dwell events for expired timers + for (geofenceId, duration) in expiredTimers { + let dwellEvent = Event( + name: .locationEvent(.geofenceDwell), + properties: [ + "$geofence_id": geofenceId, + "$geofence_dwell_duration": duration + ] + ) + + Task { + await MainActor.run { + KlaviyoInternal.create(event: dwellEvent) + } + } + + if #available(iOS 14.0, *) { + Logger.geoservices.info("🕐 Fired expired dwell event for region \(geofenceId) (expired while app was terminated)") + } + } + } } From b24771b3e5bf8db36d6412d3a28bca738af7605f Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 13:42:46 -0500 Subject: [PATCH 07/15] add DwellTimerTracker to KlaviyoLocationManager --- Sources/KlaviyoLocation/KlaviyoLocationManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 99b5482c..fa6fb434 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -19,6 +19,7 @@ class KlaviyoLocationManager: NSObject { private var apiKeyCancellable: AnyCancellable? private var lifecycleCancellable: AnyCancellable? internal let cooldownTracker = GeofenceCooldownTracker() + internal let dwellTimerTracker = DwellTimerTracker() var geofenceDwellSettings: [String: Int] = [:] var dwellTimers: [String: Timer] = [:] From 6c0962f6446c9ae822c81a17e00d5c763b75a1d4 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 13:44:09 -0500 Subject: [PATCH 08/15] Add and remove timers from DwellTimerTracker in lifecycle --- .../KlaviyoLocationManager+CLLocationManagerDelegate.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index 17b7c44f..5bb3e713 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -122,6 +122,9 @@ extension KlaviyoLocationManager { } dwellTimers[klaviyoLocationId] = timer + // Persist timer start time and duration for recovery if app terminates + dwellTimerTracker.saveTimer(geofenceId: klaviyoLocationId, startTime: environment.date().timeIntervalSince1970, duration: dwellSeconds) + if #available(iOS 14.0, *) { Logger.geoservices.info("🕐 Started dwell timer for region \(klaviyoLocationId) with \(dwellSeconds) seconds") } @@ -140,10 +143,13 @@ extension KlaviyoLocationManager { Logger.geoservices.info("🕐 Attempted to cancel dwell timer for region \(klaviyoLocationId, privacy: .public), but no dwell timer was found for that region") } } + + dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId) } private func handleDwellTimerFired(for klaviyoLocationId: String) { dwellTimers.removeValue(forKey: klaviyoLocationId) + dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId) guard let dwellDuration = geofenceDwellSettings[klaviyoLocationId] else { return } From 65fee3898b8c60e27aa29d754fdf1c923308397f Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 13:45:14 -0500 Subject: [PATCH 09/15] Check and fire expired persisted dwell events on foreground, background, awake on geo event --- .../KlaviyoLocationManager+CLLocationManagerDelegate.swift | 1 + Sources/KlaviyoLocation/KlaviyoLocationManager.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index 5bb3e713..fa95202b 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -69,6 +69,7 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { @MainActor private func handleGeofenceEvent(region: CLRegion, eventType: Event.EventName.LocationEvent) async { + await checkExpiredDwellTimers() guard let region = region as? CLCircularRegion, let klaviyoGeofence = try? region.toKlaviyoGeofence(), !klaviyoGeofence.companyId.isEmpty else { diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index fa6fb434..f37938d9 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -177,6 +177,9 @@ class KlaviyoLocationManager: NSObject { self.locationManager.startMonitoringSignificantLocationChanges() case .foregrounded, .backgrounded: self.locationManager.stopMonitoringSignificantLocationChanges() + Task { @MainActor in + await self.checkExpiredDwellTimers() + } default: break } From f99074c2f2182d985c14c4d2bf8b53680a1322e9 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 14:29:24 -0500 Subject: [PATCH 10/15] Rename some things for clarity --- ...ionManager+CLLocationManagerDelegate.swift | 32 ++++++++----------- .../KlaviyoLocationManager.swift | 28 ++++++++-------- .../Utilities/DwellTimerTracker.swift | 4 +-- 3 files changed, 31 insertions(+), 33 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index fa95202b..e53475a2 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -69,7 +69,7 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { @MainActor private func handleGeofenceEvent(region: CLRegion, eventType: Event.EventName.LocationEvent) async { - await checkExpiredDwellTimers() + checkForExpiredDwellTimers() guard let region = region as? CLCircularRegion, let klaviyoGeofence = try? region.toKlaviyoGeofence(), !klaviyoGeofence.companyId.isEmpty else { @@ -111,17 +111,14 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { extension KlaviyoLocationManager { private func startDwellTimer(for klaviyoLocationId: String) { cancelDwellTimer(for: klaviyoLocationId) - guard let dwellSeconds = geofenceDwellSettings[klaviyoLocationId] else { - if #available(iOS 14.0, *) { - Logger.geoservices.log("Dwell settings have not been specified for region \(klaviyoLocationId). Aborting dwell timer creation.") - } + guard let dwellSeconds = activeGeofenceDurations[klaviyoLocationId] else { return } let timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(dwellSeconds), repeats: false) { [weak self] _ in self?.handleDwellTimerFired(for: klaviyoLocationId) } - dwellTimers[klaviyoLocationId] = timer + currentDwellTimers[klaviyoLocationId] = timer // Persist timer start time and duration for recovery if app terminates dwellTimerTracker.saveTimer(geofenceId: klaviyoLocationId, startTime: environment.date().timeIntervalSince1970, duration: dwellSeconds) @@ -132,26 +129,25 @@ extension KlaviyoLocationManager { } private func cancelDwellTimer(for klaviyoLocationId: String) { - if let timer = dwellTimers[klaviyoLocationId] { + // remove tracking it from the persisted tracker + dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId) + + if let timer = currentDwellTimers[klaviyoLocationId] { timer.invalidate() - dwellTimers.removeValue(forKey: klaviyoLocationId) + currentDwellTimers.removeValue(forKey: klaviyoLocationId) if #available(iOS 14.0, *) { Logger.geoservices.info("🕐 Cancelled dwell timer for region \(klaviyoLocationId)") } - } else { - if #available(iOS 14.0, *) { - Logger.geoservices.info("🕐 Attempted to cancel dwell timer for region \(klaviyoLocationId, privacy: .public), but no dwell timer was found for that region") - } } - - dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId) } private func handleDwellTimerFired(for klaviyoLocationId: String) { - dwellTimers.removeValue(forKey: klaviyoLocationId) + // remove tracking in both memory and persisted tracker since it fired + currentDwellTimers.removeValue(forKey: klaviyoLocationId) dwellTimerTracker.removeTimer(geofenceId: klaviyoLocationId) - guard let dwellDuration = geofenceDwellSettings[klaviyoLocationId] else { + + guard let dwellDuration = activeGeofenceDurations[klaviyoLocationId] else { return } @@ -177,8 +173,8 @@ extension KlaviyoLocationManager { /// Check for expired timers and fire dwell events for them /// Called on app launch/foreground as a best-effort recovery mechanism @MainActor - func checkExpiredDwellTimers() { - let expiredTimers = dwellTimerTracker.checkExpiredTimers(activeTimerIds: Set(dwellTimers.keys)) + func checkForExpiredDwellTimers() { + let expiredTimers = dwellTimerTracker.getExpiredTimers(activeTimerIds: Set(currentDwellTimers.keys)) // Fire dwell events for expired timers for (geofenceId, duration) in expiredTimers { diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index f37938d9..710c8ebe 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -21,8 +21,8 @@ class KlaviyoLocationManager: NSObject { internal let cooldownTracker = GeofenceCooldownTracker() internal let dwellTimerTracker = DwellTimerTracker() - var geofenceDwellSettings: [String: Int] = [:] - var dwellTimers: [String: Timer] = [:] + var activeGeofenceDurations: [String: Int] = [:] + var currentDwellTimers: [String: Timer] = [:] init(locationManager: LocationManagerProtocol? = nil) { self.locationManager = locationManager ?? CLLocationManager() @@ -119,11 +119,11 @@ class KlaviyoLocationManager: NSObject { } klaviyoRegions.forEach(locationManager.stopMonitoring) - for timer in dwellTimers.values { + activeGeofenceDurations.removeAll() + for timer in currentDwellTimers.values { timer.invalidate() } - dwellTimers.removeAll() - geofenceDwellSettings.removeAll() + currentDwellTimers.removeAll() } // MARK: - API Key Observation @@ -151,13 +151,6 @@ class KlaviyoLocationManager: NSObject { } } - private func updateDwellSettings(_ geofences: Set) { - geofenceDwellSettings.removeAll() - for geofence in geofences { - geofenceDwellSettings[geofence.locationId] = geofence.duration - } - } - private func stopObservingAPIKeyChanges() { apiKeyCancellable?.cancel() apiKeyCancellable = nil @@ -178,7 +171,7 @@ class KlaviyoLocationManager: NSObject { case .foregrounded, .backgrounded: self.locationManager.stopMonitoringSignificantLocationChanges() Task { @MainActor in - await self.checkExpiredDwellTimers() + self.checkForExpiredDwellTimers() } default: break @@ -190,4 +183,13 @@ class KlaviyoLocationManager: NSObject { lifecycleCancellable?.cancel() lifecycleCancellable = nil } + + // MARK: - Dwell Settings Management + + private func updateDwellSettings(_ geofences: Set) { + activeGeofenceDurations.removeAll() + for geofence in geofences { + activeGeofenceDurations[geofence.locationId] = geofence.duration + } + } } diff --git a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift index a3d8a42b..77892ac7 100644 --- a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift +++ b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift @@ -63,11 +63,11 @@ class DwellTimerTracker { return timerMap } - /// Check for expired timers and return information about them + /// Check for expired timers and returns them /// /// - Parameter activeTimerIds: Set of geofence IDs that currently have active in-memory timers /// - Returns: Array of expired timer information (geofence ID and duration) - func checkExpiredTimers(activeTimerIds: Set) -> [(geofenceId: String, duration: Int)] { + func getExpiredTimers(activeTimerIds: Set) -> [(geofenceId: String, duration: Int)] { let timerMap = loadTimers() guard !timerMap.isEmpty else { return [] } From 931c3eb7ccb6c0450dde7a4b90e82d7cccb4c98c Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 14:41:39 -0500 Subject: [PATCH 11/15] Add tests --- ...ionManager+CLLocationManagerDelegate.swift | 1 + .../DwellTimerTrackerTests.swift | 255 ++++++++++++++++++ .../KlaviyoLocationManagerTests.swift | 83 ++++++ 3 files changed, 339 insertions(+) create mode 100644 Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index e53475a2..3f9875fb 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -173,6 +173,7 @@ extension KlaviyoLocationManager { /// 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(activeTimerIds: Set(currentDwellTimers.keys)) diff --git a/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift new file mode 100644 index 00000000..db45c121 --- /dev/null +++ b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift @@ -0,0 +1,255 @@ +// +// DwellTimerTrackerTests.swift +// klaviyo-swift-sdk +// +// Created by Isobelle Lim on 1/27/25. +// + +@testable import KlaviyoLocation +import Foundation +import KlaviyoCore +import KlaviyoSwift +import XCTest + +final class DwellTimerTrackerTests: XCTestCase { + var tracker: DwellTimerTracker! + var mockDate: Date! + let baseTime: TimeInterval = 1_700_000_000 // Fixed base time for testing + + override func setUp() { + super.setUp() + + // Set up mock date that we can control + mockDate = Date(timeIntervalSince1970: baseTime) + + // Set up test environment with controlled date + environment = KlaviyoEnvironment.test() + environment.date = { [weak self] in + self?.mockDate ?? Date() + } + + tracker = DwellTimerTracker() + + // Clean up any existing timer data + clearTimerData() + } + + override func tearDown() { + clearTimerData() + tracker = nil + mockDate = nil + super.tearDown() + } + + // MARK: - Helper Methods + + private func clearTimerData() { + UserDefaults.standard.removeObject(forKey: "klaviyo_dwell_timers") + } + + // MARK: - Save and Remove Tests + + func test_saveTimer_persistsTimerData() { + // GIVEN + let geofenceId = "test-geofence-1" + let startTime = baseTime + let duration = 60 + + // WHEN + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration) + + // THEN - Timer should be persisted + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired yet") + } + + func test_removeTimer_removesPersistedData() { + // GIVEN - Save a timer + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60) + + // WHEN - Remove the timer + tracker.removeTimer(geofenceId: geofenceId) + + // THEN - Timer should be gone + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimers.count, 0, "No timers should exist after removal") + } + + func test_saveTimer_overwritesExistingTimer() { + // GIVEN - Save a timer with initial duration + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60) + + // WHEN - Save again with different duration + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 120) + + // THEN - Should have the new duration + // Advance time by 70 seconds (more than 60, less than 120) + mockDate = Date(timeIntervalSince1970: baseTime + 70.0) + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimers.count, 0, "Timer with 120s duration should not be expired at 70s") + } + + // MARK: - Expired Timer Tests + + func test_getExpiredTimers_returnsExpiredTimers() { + // GIVEN - Save a timer that started 70 seconds ago with 60 second duration + let geofenceId = "test-geofence-1" + let startTime = baseTime - 70.0 + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + + // WHEN - Check for expired timers (current time is baseTime, 70s later) + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + + // THEN - Should find expired timer + XCTAssertEqual(expiredTimers.count, 1, "Should find one expired timer") + XCTAssertEqual(expiredTimers[0].geofenceId, geofenceId, "Expired timer should match geofence ID") + XCTAssertEqual(expiredTimers[0].duration, 60, "Expired timer should have correct duration") + } + + func test_getExpiredTimers_returnsEmptyWhenNoExpiredTimers() { + // GIVEN - Save a timer that started 30 seconds ago with 60 second duration + let geofenceId = "test-geofence-1" + let startTime = baseTime - 30.0 + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + + // WHEN - Check for expired timers (current time is baseTime, only 30s later) + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + + // THEN - Should not find expired timer + XCTAssertEqual(expiredTimers.count, 0, "Should not find expired timer when duration not met") + } + + func test_getExpiredTimers_returnsExpiredTimerAtBoundary() { + // GIVEN - Save a timer that started exactly 60 seconds ago with 60 second duration + let geofenceId = "test-geofence-1" + let startTime = baseTime - 60.0 + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + + // WHEN - Check for expired timers (current time is baseTime, exactly 60s later) + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + + // THEN - Should find expired timer (>= duration) + XCTAssertEqual(expiredTimers.count, 1, "Should find expired timer at boundary") + } + + func test_getExpiredTimers_handlesMultipleTimers() { + // GIVEN - Save multiple timers with different states + let geofence1 = "geofence-1" // Expired (started 70s ago, 60s duration) + let geofence2 = "geofence-2" // Not expired (started 30s ago, 60s duration) + let geofence3 = "geofence-3" // Expired (started 120s ago, 90s duration) + + tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60) + tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 30.0, duration: 60) + tracker.saveTimer(geofenceId: geofence3, startTime: baseTime - 120.0, duration: 90) + + // WHEN - Check for expired timers + mockDate = Date(timeIntervalSince1970: baseTime) + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + + // THEN - Should find only expired timers + XCTAssertEqual(expiredTimers.count, 2, "Should find two expired timers") + let expiredIds = Set(expiredTimers.map(\.geofenceId)) + XCTAssertTrue(expiredIds.contains(geofence1), "geofence1 should be expired") + XCTAssertTrue(expiredIds.contains(geofence3), "geofence3 should be expired") + XCTAssertFalse(expiredIds.contains(geofence2), "geofence2 should not be expired") + } + + // MARK: - Active Timer Deduplication Tests + + func test_getExpiredTimers_removesActiveTimersFromPersistence() { + // GIVEN - Save a timer that's not expired yet + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 30.0, duration: 60) + + // WHEN - Check with active timer ID (simulating timer was checked when app became active e.g. a geofence event triggered handler method) + mockDate = Date(timeIntervalSince1970: baseTime) + let activeTimerIds: Set = [geofenceId] + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: activeTimerIds) + + // THEN - Should not return expired timer and should remove from persistence + XCTAssertEqual(expiredTimers.count, 0, "Should not return timer that's active in memory") + + // Verify it was removed from persistence by checking again + let expiredTimersAfter = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimersAfter.count, 0, "Timer should have been removed from persistence") + } + + func test_getExpiredTimers_handlesMixOfActiveAndExpiredTimers() { + // GIVEN - Save multiple timers + let activeGeofence = "geofence-active" // Active in memory + let expiredGeofence = "geofence-expired" // Expired and not in memory + + tracker.saveTimer(geofenceId: activeGeofence, startTime: baseTime - 70.0, duration: 60) + tracker.saveTimer(geofenceId: expiredGeofence, startTime: baseTime - 70.0, duration: 60) + + // WHEN - Check with one active timer + mockDate = Date(timeIntervalSince1970: baseTime) + let activeTimerIds: Set = [activeGeofence] + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: activeTimerIds) + + // THEN - Should only return the expired timer (not the active one) + XCTAssertEqual(expiredTimers.count, 1, "Should find one expired timer") + XCTAssertEqual(expiredTimers[0].geofenceId, expiredGeofence, "Should return expired geofence") + } + + // MARK: - Cleanup Tests + + func test_getExpiredTimers_removesExpiredTimersFromPersistence() { + // GIVEN - Save an expired timer + let geofenceId = "test-geofence-1" + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 70.0, duration: 60) + + // WHEN - Check for expired timers + mockDate = Date(timeIntervalSince1970: baseTime) + _ = tracker.getExpiredTimers(activeTimerIds: []) + + // THEN - Timer should be removed from persistence + let expiredTimersAfter = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimersAfter.count, 0, "Expired timer should be removed from persistence") + } + + func test_getExpiredTimers_returnsEmptyWhenNoTimersExist() { + // GIVEN - No timers saved + + // WHEN - Check for expired timers + let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + + // THEN - Should return empty + XCTAssertEqual(expiredTimers.count, 0, "Should return empty when no timers exist") + } + + // MARK: - Integration Tests + + func test_fullTimerLifecycle() { + // GIVEN - Save a timer + let geofenceId = "test-geofence-1" + let startTime = baseTime + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + + // WHEN - Check immediately (should not be expired) + mockDate = Date(timeIntervalSince1970: baseTime) + var expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired immediately") + + // WHEN - Check after 30 seconds (should still not be expired) + mockDate = Date(timeIntervalSince1970: baseTime + 30.0) + expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired after 30 seconds") + + // WHEN - Check after 60 seconds (should be expired) + mockDate = Date(timeIntervalSince1970: baseTime + 60.0) + expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimers.count, 1, "Timer should be expired after 60 seconds") + XCTAssertEqual(expiredTimers[0].geofenceId, geofenceId, "Expired timer should match") + XCTAssertEqual(expiredTimers[0].duration, 60, "Expired timer should have correct duration") + + // WHEN - Check again (should be removed) + expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredTimers.count, 0, "Expired timer should be removed after first check") + } +} diff --git a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift index 5df81a66..47a26f71 100644 --- a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift +++ b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift @@ -21,6 +21,7 @@ final class KlaviyoLocationManagerTests: XCTestCase { var mockAuthorizationStatus: CLAuthorizationStatus = .authorizedAlways var mockApiKeyPublisher: PassthroughSubject! + var mockLifecycleEvents: PassthroughSubject! var cancellables: Set = [] override func setUp() { @@ -28,9 +29,13 @@ final class KlaviyoLocationManagerTests: XCTestCase { mockLocationManager = MockLocationManager() mockApiKeyPublisher = PassthroughSubject() + mockLifecycleEvents = PassthroughSubject() // Set up environment with mock authorization status BEFORE creating location manager environment = createMockEnvironment() + environment.appLifeCycle = AppLifeCycleEvents(lifeCycleEvents: { + self.mockLifecycleEvents.eraseToAnyPublisher() + }) // Set up state publisher BEFORE creating location manager let initialState = KlaviyoState(queue: []) @@ -56,8 +61,10 @@ final class KlaviyoLocationManagerTests: XCTestCase { locationManager = nil mockLocationManager = nil mockApiKeyPublisher = nil + mockLifecycleEvents = nil cancellables.removeAll() KlaviyoInternal.resetAPIKeySubject() + UserDefaults.standard.removeObject(forKey: "klaviyo_dwell_timers") super.tearDown() } @@ -137,6 +144,73 @@ final class KlaviyoLocationManagerTests: XCTestCase { XCTAssertEqual(locationManager.syncGeofencesCallCount, 2, "syncGeofences should be called once after foreground") } + + // MARK: - Dwell Timer Lifecycle Tests + + func test_foregroundEvent_callsCheckForExpiredDwellTimers() async { + // GIVEN + mockAuthorizationStatus = .authorizedAlways + await locationManager.startGeofenceMonitoring() + locationManager.reset() + + // WHEN - Send foreground event + mockLifecycleEvents.send(.foregrounded) + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // THEN - checkForExpiredDwellTimers should be called + XCTAssertGreaterThan(locationManager.checkForExpiredDwellTimersCallCount, 0, + "checkForExpiredDwellTimers should be called on foreground event") + } + + func test_backgroundEvent_callsCheckForExpiredDwellTimers() async { + // GIVEN + mockAuthorizationStatus = .authorizedAlways + await locationManager.startGeofenceMonitoring() + locationManager.reset() + + // WHEN - Send background event + mockLifecycleEvents.send(.backgrounded) + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // THEN - checkForExpiredDwellTimers should be called + XCTAssertGreaterThan(locationManager.checkForExpiredDwellTimersCallCount, 0, + "checkForExpiredDwellTimers should be called on background event") + } + + func test_foregroundEvent_processesExpiredTimers() async { + // GIVEN - Set up expired timer in persistence + let geofenceId = "test-geofence-1" + let baseTime: TimeInterval = 1_700_000_000 + let startTime = baseTime - 70.0 // Started 70 seconds ago + let duration = 60 // 60 second duration (expired) + + // Set up mock date + let mockDate = Date(timeIntervalSince1970: baseTime) + environment.date = { mockDate } + + // Save expired timer + locationManager.dwellTimerTracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration) + + // Verify timer exists before + let expiredBefore = locationManager.dwellTimerTracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredBefore.count, 1, "Should have one expired timer before foreground") + + mockAuthorizationStatus = .authorizedAlways + await locationManager.startGeofenceMonitoring() + locationManager.reset() + + // WHEN - Send foreground event + mockLifecycleEvents.send(.foregrounded) + try? await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // THEN - checkForExpiredDwellTimers should be called and timer should be processed + XCTAssertGreaterThan(locationManager.checkForExpiredDwellTimersCallCount, 0, + "checkForExpiredDwellTimers should be called on foreground event") + + // Verify timer was removed after processing + let expiredAfter = locationManager.dwellTimerTracker.getExpiredTimers(activeTimerIds: []) + XCTAssertEqual(expiredAfter.count, 0, "Expired timer should be removed after processing") + } } // MARK: - Mock Classes @@ -172,6 +246,8 @@ private final class MockLocationManager: LocationManagerProtocol { private final class MockKlaviyoLocationManager: KlaviyoLocationManager { var syncGeofencesCallCount: Int = 0 var stopGeofenceMonitoringCallCount: Int = 0 + var checkForExpiredDwellTimersCallCount: Int = 0 + var wasSyncGeofencesCalled: Bool { syncGeofencesCallCount > 0 } @@ -195,8 +271,15 @@ private final class MockKlaviyoLocationManager: KlaviyoLocationManager { await super.stopGeofenceMonitoring() } + @MainActor + override func checkForExpiredDwellTimers() { + checkForExpiredDwellTimersCallCount += 1 + super.checkForExpiredDwellTimers() + } + func reset() { syncGeofencesCallCount = 0 stopGeofenceMonitoringCallCount = 0 + checkForExpiredDwellTimersCallCount = 0 } } From 72f2a2baf566f82df1a096785b7a1d5effc92886 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 15:58:59 -0500 Subject: [PATCH 12/15] Restore real API call --- Sources/KlaviyoLocation/GeofenceService.swift | 68 ++++--------------- 1 file changed, 12 insertions(+), 56 deletions(-) diff --git a/Sources/KlaviyoLocation/GeofenceService.swift b/Sources/KlaviyoLocation/GeofenceService.swift index 4b4609ae..1eee8316 100644 --- a/Sources/KlaviyoLocation/GeofenceService.swift +++ b/Sources/KlaviyoLocation/GeofenceService.swift @@ -28,64 +28,20 @@ struct GeofenceService: GeofenceServiceProvider { } private func fetchGeofenceData(apiKey: String) async throws -> Data { - // MARK: - Mock JSON Response (Real fetch code commented out below) + 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) - let mockJSONString = """ - { - "data": [ - { - "type": "geofence", - "id": "geofence-1", - "attributes": { - "latitude": 37.7749, - "longitude": -122.4194, - "radius": 100.0, - "duration": 60 - } - }, - { - "type": "geofence", - "id": "geofence-2", - "attributes": { - "latitude": 40.7128, - "longitude": -74.0060, - "radius": 150.0, - "duration": 120 - } - }, - { - "type": "geofence", - "id": "geofence-3", - "attributes": { - "latitude": 34.0522, - "longitude": -118.2437, - "radius": 200.0, - "duration": null - } - } - ] + switch result { + case let .success(data): + return data + case let .failure(error): + if #available(iOS 14.0, *) { + Logger.geoservices.error("Failed to fetch geofences; error: \(error, privacy: .public)") + } + throw error } - """ - return mockJSONString.data(using: .utf8)! - - // MARK: - Real fetch code (commented out for testing) - - /* - 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) - - switch result { - case let .success(data): - return data - case let .failure(error): - if #available(iOS 14.0, *) { - Logger.geoservices.error("Failed to fetch geofences; error: \(error, privacy: .public)") - } - throw error - } - */ } func parseGeofences(from data: Data, companyId: String) throws -> Set { From 543fae4c76c6ebf83920bccf8026c4576795e91f Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Tue, 2 Dec 2025 16:57:46 -0500 Subject: [PATCH 13/15] Simplify getExpiredTimers to only concern itself with persisted ones, more clear separation --- ...ionManager+CLLocationManagerDelegate.swift | 2 +- .../KlaviyoLocationManager.swift | 1 + .../Utilities/DwellTimerTracker.swift | 30 +++----- .../DwellTimerTrackerTests.swift | 76 ++++++++++--------- .../KlaviyoLocationManagerTests.swift | 4 +- 5 files changed, 57 insertions(+), 56 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index 3f9875fb..1849f6d0 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -175,7 +175,7 @@ extension KlaviyoLocationManager { @MainActor @objc func checkForExpiredDwellTimers() { - let expiredTimers = dwellTimerTracker.getExpiredTimers(activeTimerIds: Set(currentDwellTimers.keys)) + let expiredTimers = dwellTimerTracker.getExpiredTimers() // Fire dwell events for expired timers for (geofenceId, duration) in expiredTimers { diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift index 710c8ebe..404ee4a6 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager.swift @@ -124,6 +124,7 @@ class KlaviyoLocationManager: NSObject { timer.invalidate() } currentDwellTimers.removeAll() + dwellTimerTracker.clearAllTimers() } // MARK: - API Key Observation diff --git a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift index 77892ac7..d24073a6 100644 --- a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift +++ b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift @@ -52,6 +52,12 @@ class DwellTimerTracker { 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 @@ -63,31 +69,22 @@ class DwellTimerTracker { return timerMap } - /// Check for expired timers and returns them + /// Check for expired timers, remove them from persistence, and return them /// - /// - Parameter activeTimerIds: Set of geofence IDs that currently have active in-memory timers /// - Returns: Array of expired timer information (geofence ID and duration) - func getExpiredTimers(activeTimerIds: Set) -> [(geofenceId: String, duration: Int)] { + func getExpiredTimers() -> [(geofenceId: String, duration: Int)] { let timerMap = loadTimers() guard !timerMap.isEmpty else { return [] } let currentTime = environment.date().timeIntervalSince1970 var expiredTimers: [(geofenceId: String, duration: Int)] = [] - var timersToRemove: [String] = [] for (geofenceId, timerData) in timerMap { - // If timer exists in memory, it's already active - remove from persistence to avoid duplication - if activeTimerIds.contains(geofenceId) { - timersToRemove.append(geofenceId) - continue - } - - let age = currentTime - timerData.startTime - // Check if timer expired (elapsed >= duration) - if age >= TimeInterval(timerData.duration) { - timersToRemove.append(geofenceId) + if currentTime - timerData.startTime >= TimeInterval(timerData.duration) { expiredTimers.append((geofenceId: geofenceId, duration: timerData.duration)) + // 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)") @@ -95,11 +92,6 @@ class DwellTimerTracker { } } - // Clean up all timers that need to be removed - for geofenceId in timersToRemove { - removeTimer(geofenceId: geofenceId) - } - return expiredTimers } } diff --git a/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift index db45c121..13cb9069 100644 --- a/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift +++ b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift @@ -59,7 +59,7 @@ final class DwellTimerTrackerTests: XCTestCase { tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration) // THEN - Timer should be persisted - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired yet") } @@ -72,7 +72,7 @@ final class DwellTimerTrackerTests: XCTestCase { tracker.removeTimer(geofenceId: geofenceId) // THEN - Timer should be gone - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() XCTAssertEqual(expiredTimers.count, 0, "No timers should exist after removal") } @@ -87,7 +87,7 @@ final class DwellTimerTrackerTests: XCTestCase { // THEN - Should have the new duration // Advance time by 70 seconds (more than 60, less than 120) mockDate = Date(timeIntervalSince1970: baseTime + 70.0) - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() XCTAssertEqual(expiredTimers.count, 0, "Timer with 120s duration should not be expired at 70s") } @@ -101,7 +101,7 @@ final class DwellTimerTrackerTests: XCTestCase { // WHEN - Check for expired timers (current time is baseTime, 70s later) mockDate = Date(timeIntervalSince1970: baseTime) - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() // THEN - Should find expired timer XCTAssertEqual(expiredTimers.count, 1, "Should find one expired timer") @@ -117,7 +117,7 @@ final class DwellTimerTrackerTests: XCTestCase { // WHEN - Check for expired timers (current time is baseTime, only 30s later) mockDate = Date(timeIntervalSince1970: baseTime) - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() // THEN - Should not find expired timer XCTAssertEqual(expiredTimers.count, 0, "Should not find expired timer when duration not met") @@ -131,7 +131,7 @@ final class DwellTimerTrackerTests: XCTestCase { // WHEN - Check for expired timers (current time is baseTime, exactly 60s later) mockDate = Date(timeIntervalSince1970: baseTime) - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() // THEN - Should find expired timer (>= duration) XCTAssertEqual(expiredTimers.count, 1, "Should find expired timer at boundary") @@ -149,7 +149,7 @@ final class DwellTimerTrackerTests: XCTestCase { // WHEN - Check for expired timers mockDate = Date(timeIntervalSince1970: baseTime) - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() // THEN - Should find only expired timers XCTAssertEqual(expiredTimers.count, 2, "Should find two expired timers") @@ -161,40 +161,48 @@ final class DwellTimerTrackerTests: XCTestCase { // MARK: - Active Timer Deduplication Tests - func test_getExpiredTimers_removesActiveTimersFromPersistence() { + func test_getExpiredTimers_doesNotRemoveActiveTimersFromPersistence() { // GIVEN - Save a timer that's not expired yet let geofenceId = "test-geofence-1" tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 30.0, duration: 60) - // WHEN - Check with active timer ID (simulating timer was checked when app became active e.g. a geofence event triggered handler method) + // WHEN - Check for expired timers (timer is not expired, so won't be returned) mockDate = Date(timeIntervalSince1970: baseTime) - let activeTimerIds: Set = [geofenceId] - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: activeTimerIds) + let expiredTimers = tracker.getExpiredTimers() - // THEN - Should not return expired timer and should remove from persistence - XCTAssertEqual(expiredTimers.count, 0, "Should not return timer that's active in memory") + // THEN - Should not return timer (it's not expired) + XCTAssertEqual(expiredTimers.count, 0, "Should not return timer that hasn't expired") - // Verify it was removed from persistence by checking again - let expiredTimersAfter = tracker.getExpiredTimers(activeTimerIds: []) - XCTAssertEqual(expiredTimersAfter.count, 0, "Timer should have been removed from persistence") + // Verify timer is still in persistence (not removed because it's not expired) + let expiredTimersAfter = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimersAfter.count, 0, "Timer should still not be expired") + + // Verify timer data still exists in persistence by checking if it would be expired later + mockDate = Date(timeIntervalSince1970: baseTime + 40.0) // 70 seconds total (expired) + let expiredTimersLater = tracker.getExpiredTimers() + XCTAssertEqual(expiredTimersLater.count, 1, "Timer should be expired after enough time passes") } func test_getExpiredTimers_handlesMixOfActiveAndExpiredTimers() { - // GIVEN - Save multiple timers - let activeGeofence = "geofence-active" // Active in memory - let expiredGeofence = "geofence-expired" // Expired and not in memory + // GIVEN - Save multiple timers (both expired) + // Note: In practice, if a timer is active in memory, persistence should have been + // updated when it started. This test verifies that expired timers are returned + // regardless of activeTimerIds parameter (which is now unused but kept for API compatibility) + let geofence1 = "geofence-1" + let geofence2 = "geofence-2" - tracker.saveTimer(geofenceId: activeGeofence, startTime: baseTime - 70.0, duration: 60) - tracker.saveTimer(geofenceId: expiredGeofence, startTime: baseTime - 70.0, duration: 60) + tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60) + tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 70.0, duration: 60) - // WHEN - Check with one active timer + // WHEN - Check for expired timers mockDate = Date(timeIntervalSince1970: baseTime) - let activeTimerIds: Set = [activeGeofence] - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: activeTimerIds) + let expiredTimers = tracker.getExpiredTimers() - // THEN - Should only return the expired timer (not the active one) - XCTAssertEqual(expiredTimers.count, 1, "Should find one expired timer") - XCTAssertEqual(expiredTimers[0].geofenceId, expiredGeofence, "Should return expired geofence") + // THEN - Should return all expired timers (activeTimerIds parameter is no longer used) + XCTAssertEqual(expiredTimers.count, 2, "Should find both expired timers") + let expiredIds = Set(expiredTimers.map(\.geofenceId)) + XCTAssertTrue(expiredIds.contains(geofence1), "Should return geofence1") + XCTAssertTrue(expiredIds.contains(geofence2), "Should return geofence2") } // MARK: - Cleanup Tests @@ -206,10 +214,10 @@ final class DwellTimerTrackerTests: XCTestCase { // WHEN - Check for expired timers mockDate = Date(timeIntervalSince1970: baseTime) - _ = tracker.getExpiredTimers(activeTimerIds: []) + _ = tracker.getExpiredTimers() // THEN - Timer should be removed from persistence - let expiredTimersAfter = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimersAfter = tracker.getExpiredTimers() XCTAssertEqual(expiredTimersAfter.count, 0, "Expired timer should be removed from persistence") } @@ -217,7 +225,7 @@ final class DwellTimerTrackerTests: XCTestCase { // GIVEN - No timers saved // WHEN - Check for expired timers - let expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + let expiredTimers = tracker.getExpiredTimers() // THEN - Should return empty XCTAssertEqual(expiredTimers.count, 0, "Should return empty when no timers exist") @@ -233,23 +241,23 @@ final class DwellTimerTrackerTests: XCTestCase { // WHEN - Check immediately (should not be expired) mockDate = Date(timeIntervalSince1970: baseTime) - var expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + var expiredTimers = tracker.getExpiredTimers() XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired immediately") // WHEN - Check after 30 seconds (should still not be expired) mockDate = Date(timeIntervalSince1970: baseTime + 30.0) - expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + expiredTimers = tracker.getExpiredTimers() XCTAssertEqual(expiredTimers.count, 0, "Timer should not be expired after 30 seconds") // WHEN - Check after 60 seconds (should be expired) mockDate = Date(timeIntervalSince1970: baseTime + 60.0) - expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + expiredTimers = tracker.getExpiredTimers() XCTAssertEqual(expiredTimers.count, 1, "Timer should be expired after 60 seconds") XCTAssertEqual(expiredTimers[0].geofenceId, geofenceId, "Expired timer should match") XCTAssertEqual(expiredTimers[0].duration, 60, "Expired timer should have correct duration") // WHEN - Check again (should be removed) - expiredTimers = tracker.getExpiredTimers(activeTimerIds: []) + expiredTimers = tracker.getExpiredTimers() XCTAssertEqual(expiredTimers.count, 0, "Expired timer should be removed after first check") } } diff --git a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift index 47a26f71..ed9624c6 100644 --- a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift +++ b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift @@ -192,7 +192,7 @@ final class KlaviyoLocationManagerTests: XCTestCase { locationManager.dwellTimerTracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration) // Verify timer exists before - let expiredBefore = locationManager.dwellTimerTracker.getExpiredTimers(activeTimerIds: []) + let expiredBefore = locationManager.dwellTimerTracker.getExpiredTimers() XCTAssertEqual(expiredBefore.count, 1, "Should have one expired timer before foreground") mockAuthorizationStatus = .authorizedAlways @@ -208,7 +208,7 @@ final class KlaviyoLocationManagerTests: XCTestCase { "checkForExpiredDwellTimers should be called on foreground event") // Verify timer was removed after processing - let expiredAfter = locationManager.dwellTimerTracker.getExpiredTimers(activeTimerIds: []) + let expiredAfter = locationManager.dwellTimerTracker.getExpiredTimers() XCTAssertEqual(expiredAfter.count, 0, "Expired timer should be removed after processing") } } From 6575fcc005bce0266ac2803cb09f1f3dbb628d51 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Wed, 3 Dec 2025 11:20:29 -0500 Subject: [PATCH 14/15] Modify DwellTimerData to contain apiKey to make correct createGeofenceEvent call --- ...ionManager+CLLocationManagerDelegate.swift | 22 +++++-------- .../Utilities/DwellTimerTracker.swift | 14 ++++---- .../DwellTimerTrackerTests.swift | 33 ++++++++++--------- .../KlaviyoLocationManagerTests.swift | 2 +- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index 1849f6d0..b030c733 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -99,7 +99,7 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { await KlaviyoInternal.createGeofenceEvent(event: event, for: klaviyoGeofence.companyId) if eventType == .geofenceEnter { - startDwellTimer(for: klaviyoLocationId) + startDwellTimer(for: klaviyoLocationId, companyId: klaviyoGeofence.companyId) } else { cancelDwellTimer(for: klaviyoLocationId) } @@ -109,19 +109,19 @@ extension KlaviyoLocationManager: CLLocationManagerDelegate { // MARK: Dwell Timer Management extension KlaviyoLocationManager { - private func startDwellTimer(for klaviyoLocationId: String) { + 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) + self?.handleDwellTimerFired(for: klaviyoLocationId, apiKey: companyId) } currentDwellTimers[klaviyoLocationId] = timer - // Persist timer start time and duration for recovery if app terminates - dwellTimerTracker.saveTimer(geofenceId: klaviyoLocationId, startTime: environment.date().timeIntervalSince1970, duration: dwellSeconds) + // 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") @@ -142,7 +142,7 @@ extension KlaviyoLocationManager { } } - private func handleDwellTimerFired(for klaviyoLocationId: String) { + 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) @@ -160,9 +160,7 @@ extension KlaviyoLocationManager { ) Task { - await MainActor.run { - KlaviyoInternal.create(event: dwellEvent) - } + await KlaviyoInternal.createGeofenceEvent(event: dwellEvent, for: apiKey) } if #available(iOS 14.0, *) { @@ -178,7 +176,7 @@ extension KlaviyoLocationManager { let expiredTimers = dwellTimerTracker.getExpiredTimers() // Fire dwell events for expired timers - for (geofenceId, duration) in expiredTimers { + for (geofenceId, duration, companyId) in expiredTimers { let dwellEvent = Event( name: .locationEvent(.geofenceDwell), properties: [ @@ -188,9 +186,7 @@ extension KlaviyoLocationManager { ) Task { - await MainActor.run { - KlaviyoInternal.create(event: dwellEvent) - } + await KlaviyoInternal.createGeofenceEvent(event: dwellEvent, for: companyId) } if #available(iOS 14.0, *) { diff --git a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift index d24073a6..06da73f6 100644 --- a/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift +++ b/Sources/KlaviyoLocation/Utilities/DwellTimerTracker.swift @@ -21,6 +21,7 @@ class DwellTimerTracker { private struct DwellTimerData: Codable { let startTime: TimeInterval let duration: Int + let companyId: String } /// Save dwell timer data to UserDefaults @@ -29,9 +30,10 @@ class DwellTimerTracker { /// - geofenceId: The geofence location ID /// - startTime: The timestamp when the timer started /// - duration: The duration of the timer in seconds - func saveTimer(geofenceId: String, startTime: TimeInterval, duration: Int) { + /// - 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) + timerMap[geofenceId] = DwellTimerData(startTime: startTime, duration: duration, companyId: companyId) guard let data = try? JSONEncoder().encode(timerMap) else { return @@ -71,18 +73,18 @@ class DwellTimerTracker { /// Check for expired timers, remove them from persistence, and return them /// - /// - Returns: Array of expired timer information (geofence ID and duration) - func getExpiredTimers() -> [(geofenceId: String, duration: Int)] { + /// - 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)] = [] + 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) { - expiredTimers.append((geofenceId: geofenceId, duration: timerData.duration)) + expiredTimers.append((geofenceId: geofenceId, duration: timerData.duration, companyId: timerData.companyId)) // Remove expired timer from persistence removeTimer(geofenceId: geofenceId) diff --git a/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift index 13cb9069..1e66ca6f 100644 --- a/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift +++ b/Tests/KlaviyoLocationTests/DwellTimerTrackerTests.swift @@ -15,6 +15,7 @@ final class DwellTimerTrackerTests: XCTestCase { var tracker: DwellTimerTracker! var mockDate: Date! let baseTime: TimeInterval = 1_700_000_000 // Fixed base time for testing + let testCompanyId = "test-company-id" override func setUp() { super.setUp() @@ -56,7 +57,7 @@ final class DwellTimerTrackerTests: XCTestCase { let duration = 60 // WHEN - tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration) + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration, companyId: testCompanyId) // THEN - Timer should be persisted let expiredTimers = tracker.getExpiredTimers() @@ -66,7 +67,7 @@ final class DwellTimerTrackerTests: XCTestCase { func test_removeTimer_removesPersistedData() { // GIVEN - Save a timer let geofenceId = "test-geofence-1" - tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60, companyId: testCompanyId) // WHEN - Remove the timer tracker.removeTimer(geofenceId: geofenceId) @@ -79,10 +80,10 @@ final class DwellTimerTrackerTests: XCTestCase { func test_saveTimer_overwritesExistingTimer() { // GIVEN - Save a timer with initial duration let geofenceId = "test-geofence-1" - tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 60, companyId: testCompanyId) // WHEN - Save again with different duration - tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 120) + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime, duration: 120, companyId: testCompanyId) // THEN - Should have the new duration // Advance time by 70 seconds (more than 60, less than 120) @@ -97,7 +98,7 @@ final class DwellTimerTrackerTests: XCTestCase { // GIVEN - Save a timer that started 70 seconds ago with 60 second duration let geofenceId = "test-geofence-1" let startTime = baseTime - 70.0 - tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) // WHEN - Check for expired timers (current time is baseTime, 70s later) mockDate = Date(timeIntervalSince1970: baseTime) @@ -107,13 +108,14 @@ final class DwellTimerTrackerTests: XCTestCase { XCTAssertEqual(expiredTimers.count, 1, "Should find one expired timer") XCTAssertEqual(expiredTimers[0].geofenceId, geofenceId, "Expired timer should match geofence ID") XCTAssertEqual(expiredTimers[0].duration, 60, "Expired timer should have correct duration") + XCTAssertEqual(expiredTimers[0].companyId, testCompanyId, "Expired timer should have correct company ID") } func test_getExpiredTimers_returnsEmptyWhenNoExpiredTimers() { // GIVEN - Save a timer that started 30 seconds ago with 60 second duration let geofenceId = "test-geofence-1" let startTime = baseTime - 30.0 - tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) // WHEN - Check for expired timers (current time is baseTime, only 30s later) mockDate = Date(timeIntervalSince1970: baseTime) @@ -127,7 +129,7 @@ final class DwellTimerTrackerTests: XCTestCase { // GIVEN - Save a timer that started exactly 60 seconds ago with 60 second duration let geofenceId = "test-geofence-1" let startTime = baseTime - 60.0 - tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) // WHEN - Check for expired timers (current time is baseTime, exactly 60s later) mockDate = Date(timeIntervalSince1970: baseTime) @@ -143,9 +145,9 @@ final class DwellTimerTrackerTests: XCTestCase { let geofence2 = "geofence-2" // Not expired (started 30s ago, 60s duration) let geofence3 = "geofence-3" // Expired (started 120s ago, 90s duration) - tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60) - tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 30.0, duration: 60) - tracker.saveTimer(geofenceId: geofence3, startTime: baseTime - 120.0, duration: 90) + tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) + tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 30.0, duration: 60, companyId: testCompanyId) + tracker.saveTimer(geofenceId: geofence3, startTime: baseTime - 120.0, duration: 90, companyId: testCompanyId) // WHEN - Check for expired timers mockDate = Date(timeIntervalSince1970: baseTime) @@ -164,7 +166,7 @@ final class DwellTimerTrackerTests: XCTestCase { func test_getExpiredTimers_doesNotRemoveActiveTimersFromPersistence() { // GIVEN - Save a timer that's not expired yet let geofenceId = "test-geofence-1" - tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 30.0, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 30.0, duration: 60, companyId: testCompanyId) // WHEN - Check for expired timers (timer is not expired, so won't be returned) mockDate = Date(timeIntervalSince1970: baseTime) @@ -191,8 +193,8 @@ final class DwellTimerTrackerTests: XCTestCase { let geofence1 = "geofence-1" let geofence2 = "geofence-2" - tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60) - tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 70.0, duration: 60) + tracker.saveTimer(geofenceId: geofence1, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) + tracker.saveTimer(geofenceId: geofence2, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) // WHEN - Check for expired timers mockDate = Date(timeIntervalSince1970: baseTime) @@ -210,7 +212,7 @@ final class DwellTimerTrackerTests: XCTestCase { func test_getExpiredTimers_removesExpiredTimersFromPersistence() { // GIVEN - Save an expired timer let geofenceId = "test-geofence-1" - tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 70.0, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: baseTime - 70.0, duration: 60, companyId: testCompanyId) // WHEN - Check for expired timers mockDate = Date(timeIntervalSince1970: baseTime) @@ -237,7 +239,7 @@ final class DwellTimerTrackerTests: XCTestCase { // GIVEN - Save a timer let geofenceId = "test-geofence-1" let startTime = baseTime - tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60) + tracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: 60, companyId: testCompanyId) // WHEN - Check immediately (should not be expired) mockDate = Date(timeIntervalSince1970: baseTime) @@ -255,6 +257,7 @@ final class DwellTimerTrackerTests: XCTestCase { XCTAssertEqual(expiredTimers.count, 1, "Timer should be expired after 60 seconds") XCTAssertEqual(expiredTimers[0].geofenceId, geofenceId, "Expired timer should match") XCTAssertEqual(expiredTimers[0].duration, 60, "Expired timer should have correct duration") + XCTAssertEqual(expiredTimers[0].companyId, testCompanyId, "Expired timer should have correct company ID") // WHEN - Check again (should be removed) expiredTimers = tracker.getExpiredTimers() diff --git a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift index ed9624c6..8c56918e 100644 --- a/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift +++ b/Tests/KlaviyoLocationTests/KlaviyoLocationManagerTests.swift @@ -189,7 +189,7 @@ final class KlaviyoLocationManagerTests: XCTestCase { environment.date = { mockDate } // Save expired timer - locationManager.dwellTimerTracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration) + locationManager.dwellTimerTracker.saveTimer(geofenceId: geofenceId, startTime: startTime, duration: duration, companyId: "test-company-id") // Verify timer exists before let expiredBefore = locationManager.dwellTimerTracker.getExpiredTimers() From 44a47de0cbfa4aedf07cf8964e2cef0a7b2b51d6 Mon Sep 17 00:00:00 2001 From: Isobelle Lim Date: Wed, 3 Dec 2025 11:23:21 -0500 Subject: [PATCH 15/15] Invalidate tiemrs in memory if they fired from persistence --- .../KlaviyoLocationManager+CLLocationManagerDelegate.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift index b030c733..ef4ee204 100644 --- a/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift +++ b/Sources/KlaviyoLocation/KlaviyoLocationManager+CLLocationManagerDelegate.swift @@ -177,6 +177,12 @@ extension KlaviyoLocationManager { // 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: [