diff --git a/geofence/src/main/kotlin/io/customer/geofence/GeofenceRepository.kt b/geofence/src/main/kotlin/io/customer/geofence/GeofenceRepository.kt index b4414551a..bb92ad14b 100644 --- a/geofence/src/main/kotlin/io/customer/geofence/GeofenceRepository.kt +++ b/geofence/src/main/kotlin/io/customer/geofence/GeofenceRepository.kt @@ -59,8 +59,7 @@ internal class GeofenceRepositoryImpl( private val manager: GeofenceManager, private val secureUserStore: SecureUserStore, private val clock: Clock, - private val logger: GeofenceLogger, - private val syncMode: GeofenceSyncMode + private val logger: GeofenceLogger ) : GeofenceRepository { // Dedup gate shared by refresh() and handleMovement(). If either is already running, @@ -99,8 +98,7 @@ internal class GeofenceRepositoryImpl( } } - // Decision table for identify/launch refresh. Only the re-fetch question depends on the sync - // mode; staleness in time, staleness of the ranking, and OS-registration gaps are mode-agnostic. + // Decision table for identify/launch refresh. private fun refreshAction(location: LocationCoordinates, config: GeofenceConfig): RefreshAction { // Each distance is measured from its own reference: re-fetch from the last API fetch, re-rank // from the last registration (the movement-trigger center). Null (never set) → 0 → within radius. @@ -111,7 +109,7 @@ internal class GeofenceRepositoryImpl( return when { isStaleInTime(config) -> RefreshAction.REMOTE - syncMode.movementRequiresRemoteFetch(distanceFromLastFetch, config) -> RefreshAction.REMOTE + movedBeyondFetchRadius(distanceFromLastFetch, config) -> RefreshAction.REMOTE isRankingStale(distanceFromLastRegistration, config) -> RefreshAction.LOCAL hasUnregisteredCache() -> RefreshAction.LOCAL // Without this a fresh-cache launch after a reboot would SKIP — registeredIds survive the @@ -133,6 +131,11 @@ internal class GeofenceRepositoryImpl( private fun isRankingStale(distanceFromLastRegistration: Float, config: GeofenceConfig): Boolean = distanceFromLastRegistration >= config.localRefreshTriggerRadius + // The cached set only covers the area around the last fetch; once the device moves past the fetch + // radius the set is no longer "nearby", so re-fetch from the server. + private fun movedBeyondFetchRadius(distanceFromAnchor: Float, config: GeofenceConfig): Boolean = + distanceFromAnchor >= config.remoteFetchRefreshTriggerRadius + /** Cache holds regions but none are registered with the OS (e.g. regs lost on sign-out) → re-register. */ private fun hasUnregisteredCache(): Boolean = store.getCachedRegions().isNotEmpty() && store.getRegisteredIds().isEmpty() @@ -165,7 +168,7 @@ internal class GeofenceRepositoryImpl( // No anchor yet (first EXIT after install / clearAll / sign-out) bootstraps from the server. // Otherwise, a non-remote move always re-ranks locally — that's the floor for any EXIT. val needsRemoteFetch = anchor == null || - syncMode.movementRequiresRemoteFetch(distanceFromAnchor, config) + movedBeyondFetchRadius(distanceFromAnchor, config) return if (needsRemoteFetch) { performRemoteRefresh(userId, latitude, longitude) } else { @@ -216,11 +219,11 @@ internal class GeofenceRepositoryImpl( latitude: Double, longitude: Double ): Result { - // NEARBY sends the device location so the backend can return the nearby set; FETCH_ALL sends - // none. The request carries no user identity, so the location isn't attributable to a user. - // Search radius comes from the cached config (or fallback) — the fresh config only arrives in - // this response, so it can't inform the request that fetches it. - val fetchLocation = if (syncMode == GeofenceSyncMode.NEARBY) GeofenceLocation(latitude, longitude) else null + // The device location lets the backend return the nearby set; the request carries no user + // identity, so it isn't attributable to a user. Search radius comes from the cached config + // (or fallback) — the fresh config only arrives in this response, so it can't inform the + // request that fetches it. + val fetchLocation = GeofenceLocation(latitude, longitude) val requestRadiusMeters = store.getCachedConfigOrFallback().remoteSearchRadiusMeters() val fetchResult = apiService.fetchGeofences(fetchLocation, requestRadiusMeters) return fetchResult.fold( diff --git a/geofence/src/main/kotlin/io/customer/geofence/GeofenceSyncMode.kt b/geofence/src/main/kotlin/io/customer/geofence/GeofenceSyncMode.kt deleted file mode 100644 index 3aba87594..000000000 --- a/geofence/src/main/kotlin/io/customer/geofence/GeofenceSyncMode.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.customer.geofence - -/** - * The kind of refresh a signal calls for, decided independently of what triggered it. - * - * - [REMOTE] — fetch a fresh set from the API. - * - [LOCAL] — re-rank / re-register the cached set on-device, no network. - * - [SKIP] — cache is current; do nothing. - */ -internal enum class RefreshAction { REMOTE, LOCAL, SKIP } - -/** - * How the SDK fetches a user's geofences. - * - * - [FETCH_ALL] — the backend returns the full (capped) set and the SDK sends no device location. - * - [NEARBY] — the SDK sends the device location and the backend returns the nearby set; - * it then re-fetches once the device moves beyond [GeofenceConfig.remoteFetchRefreshTriggerRadius]. - * - * The active mode is a deliberate SDK-release decision ([active]), never a runtime/server toggle — - * [NEARBY] is the only mode that transmits device location, so enabling it must be explicit. - */ -internal enum class GeofenceSyncMode { - FETCH_ALL { - // FETCH_ALL holds the full set, so movement never triggers a re-fetch — only a local re-rank. - override fun movementRequiresRemoteFetch(distanceFromAnchor: Float, config: GeofenceConfig): Boolean = false - }, - NEARBY { - // The cached set only covers the area around the last fetch; once the device moves past the - // fetch radius the set is no longer "nearby", so re-fetch from the server. - override fun movementRequiresRemoteFetch(distanceFromAnchor: Float, config: GeofenceConfig): Boolean = - distanceFromAnchor >= config.remoteFetchRefreshTriggerRadius - }; - - /** Whether a movement that far from the last-fetch anchor warrants a fresh server fetch. */ - abstract fun movementRequiresRemoteFetch(distanceFromAnchor: Float, config: GeofenceConfig): Boolean - - companion object { - val active: GeofenceSyncMode = NEARBY - } -} diff --git a/geofence/src/main/kotlin/io/customer/geofence/RefreshAction.kt b/geofence/src/main/kotlin/io/customer/geofence/RefreshAction.kt new file mode 100644 index 000000000..8b866f26e --- /dev/null +++ b/geofence/src/main/kotlin/io/customer/geofence/RefreshAction.kt @@ -0,0 +1,10 @@ +package io.customer.geofence + +/** + * The kind of refresh a signal calls for, decided independently of what triggered it. + * + * - [REMOTE] — fetch a fresh set from the API. + * - [LOCAL] — re-rank / re-register the cached set on-device, no network. + * - [SKIP] — cache is current; do nothing. + */ +internal enum class RefreshAction { REMOTE, LOCAL, SKIP } diff --git a/geofence/src/main/kotlin/io/customer/geofence/api/GeofenceApiService.kt b/geofence/src/main/kotlin/io/customer/geofence/api/GeofenceApiService.kt index 8a412549c..cad6e7dba 100644 --- a/geofence/src/main/kotlin/io/customer/geofence/api/GeofenceApiService.kt +++ b/geofence/src/main/kotlin/io/customer/geofence/api/GeofenceApiService.kt @@ -10,14 +10,12 @@ import kotlinx.serialization.Serializable internal interface GeofenceApiService { /** - * Fetches geofences. When [location] is non-null (NEARBY) it's sent with [radiusMeters] in the - * request body so the backend returns the nearest set within that radius; when null (FETCH_ALL) - * no body is sent and the backend returns the full (capped) set. The request carries no user - * identity, so the location is not attributable to a user. + * Fetches the nearest geofences to [location] within [radiusMeters], sent in the request body. + * The request carries no user identity, so the location is not attributable to a user. */ suspend fun fetchGeofences( - location: GeofenceLocation? = null, - radiusMeters: Double = 0.0 + location: GeofenceLocation, + radiusMeters: Double ): Result } @@ -27,16 +25,14 @@ internal class GeofenceApiServiceImpl( ) : GeofenceApiService { override suspend fun fetchGeofences( - location: GeofenceLocation?, + location: GeofenceLocation, radiusMeters: Double ): Result { // `limit` is optional on the endpoint and omitted — the SDK caps the count locally. - val body = location?.let { - jsonSerializer.encode( - GeofenceNearestRequest.serializer(), - GeofenceNearestRequest(latitude = it.latitude, longitude = it.longitude, radius = radiusMeters) - ) - } + val body = jsonSerializer.encode( + GeofenceNearestRequest.serializer(), + GeofenceNearestRequest(latitude = location.latitude, longitude = location.longitude, radius = radiusMeters) + ) val params = HttpRequestParams( path = ENDPOINT_PATH, method = HttpMethod.POST, diff --git a/geofence/src/main/kotlin/io/customer/geofence/di/DIGraphGeofence.kt b/geofence/src/main/kotlin/io/customer/geofence/di/DIGraphGeofence.kt index 8d46c75fa..e75626f26 100644 --- a/geofence/src/main/kotlin/io/customer/geofence/di/DIGraphGeofence.kt +++ b/geofence/src/main/kotlin/io/customer/geofence/di/DIGraphGeofence.kt @@ -13,7 +13,6 @@ import io.customer.geofence.GeofenceRepository import io.customer.geofence.GeofenceRepositoryImpl import io.customer.geofence.GeofenceServices import io.customer.geofence.GeofenceServicesImpl -import io.customer.geofence.GeofenceSyncMode import io.customer.geofence.api.GeofenceApiService import io.customer.geofence.api.GeofenceApiServiceImpl import io.customer.geofence.store.GeofenceCooldownStore @@ -128,8 +127,7 @@ internal val AndroidSDKComponent.geofenceRepository: GeofenceRepository manager = geofenceManager, secureUserStore = secureUserStore, clock = SDKComponent.clock, - logger = SDKComponent.geofenceLogger, - syncMode = GeofenceSyncMode.active + logger = SDKComponent.geofenceLogger ) } diff --git a/geofence/src/test/java/io/customer/geofence/GeofenceRepositoryTest.kt b/geofence/src/test/java/io/customer/geofence/GeofenceRepositoryTest.kt index 0a383637b..a6cbe446d 100644 --- a/geofence/src/test/java/io/customer/geofence/GeofenceRepositoryTest.kt +++ b/geofence/src/test/java/io/customer/geofence/GeofenceRepositoryTest.kt @@ -47,20 +47,17 @@ class GeofenceRepositoryTest : RobolectricTest() { // Default: mirror real time so tests using relative timestamps work // without churn. Override for deterministic timing. every { clock.currentTimeMillis() } answers { System.currentTimeMillis() } - // Default to FETCH_ALL so these tests pin that mode explicitly; NEARBY tests rebuild via - // buildRepository(NEARBY). (GeofenceSyncMode.active is covered in GeofenceSyncModeTest.) - repository = buildRepository(GeofenceSyncMode.FETCH_ALL) + repository = buildRepository() } - private fun buildRepository(syncMode: GeofenceSyncMode) = GeofenceRepositoryImpl( + private fun buildRepository() = GeofenceRepositoryImpl( apiService = apiService, store = store, distanceFilter = distanceFilter, manager = manager, secureUserStore = secureUserStore, clock = clock, - logger = logger, - syncMode = syncMode + logger = logger ) @Test @@ -166,7 +163,7 @@ class GeofenceRepositoryTest : RobolectricTest() { } @Test - fun refresh_givenFetchAllMode_expectFetchWithoutLocation() = runTest { + fun refresh_givenNeverSynced_expectRemoteFetchWithLocation() = runTest { every { secureUserStore.getUserId() } returns "user-42" every { store.getLastSyncTimestamp() } returns null // never synced -> remote fetch coEvery { apiService.fetchGeofences(any(), any()) } returns @@ -176,50 +173,15 @@ class GeofenceRepositoryTest : RobolectricTest() { repository.refresh(latitude = 37.7749, longitude = -122.4194) - // Fetch-all sends no location; precise lat/lng stay on-device. - coVerify { apiService.fetchGeofences(null, any()) } - } - - @Test - fun handleMovement_givenFetchAllModeAndMovedFar_expectLocalRefreshNotRemote() = runTest { - val cached = listOf(GeofenceRegion("biz-1", 0.0, 0.0, 100f)) - every { secureUserStore.getUserId() } returns "user-42" - every { store.getLastApiFetchLocation() } returns GeofenceLocation(0.0, 0.0) - every { store.getCachedConfig() } returns sampleConfig() - every { store.getCachedRegions() } returns cached - every { store.getRegisteredIds() } returns setOf("biz-1") - every { distanceFilter.nearest(any(), any(), any(), any(), any()) } returns cached - coEvery { manager.replaceGeofences(any(), any()) } returns Result.success(Unit) - - // 1° latitude ≈ 111 km — far beyond any radius — but fetch-all never re-fetches on movement. - repository.handleMovement(latitude = 1.0, longitude = 0.0) - - coVerify(exactly = 0) { apiService.fetchGeofences(any(), any()) } - coVerify { manager.replaceGeofences(any(), any()) } - } - - @Test - fun refresh_givenNearbyMode_expectFetchWithLocation() = runTest { - repository = buildRepository(GeofenceSyncMode.NEARBY) - every { secureUserStore.getUserId() } returns "user-42" - every { store.getLastSyncTimestamp() } returns null // never synced -> remote fetch - coEvery { apiService.fetchGeofences(any(), any()) } returns - Result.success(sampleResponse(maxBusinessGeofences = 3)) - every { distanceFilter.nearest(any(), any(), any(), any(), any()) } returns emptyList() - coEvery { manager.replaceGeofences(any(), any()) } returns Result.success(Unit) - - repository.refresh(latitude = 37.7749, longitude = -122.4194) - - // NEARBY sends the device location to the API. + // The device location is sent to the API. coVerify { apiService.fetchGeofences(GeofenceLocation(37.7749, -122.4194), any()) } } @Test - fun refresh_givenNearbyMode_expectSearchRadiusFromConfig() = runTest { + fun refresh_givenRemoteFetch_expectSearchRadiusFromConfig() = runTest { // radius = max(maxMonitoringDistance, remoteFetchRefreshTriggerRadius). Here the monitoring // cap (2 km) is tighter than the re-fetch radius (5 km), so the re-fetch radius wins — the // set must still cover everywhere the device can travel before the next re-fetch. - repository = buildRepository(GeofenceSyncMode.NEARBY) every { secureUserStore.getUserId() } returns "user-42" every { store.getLastSyncTimestamp() } returns null every { store.getCachedConfig() } returns sampleConfig(maxMonitoringDistance = 2_000f) @@ -235,10 +197,9 @@ class GeofenceRepositoryTest : RobolectricTest() { } @Test - fun refresh_givenNearbyModeFreshButMovedFar_expectRemoteFetchWithLocation() = runTest { - // NEARBY counterpart to the fetch-all "fresh but moved far" test: even within the freshness - // window, moving past the fetch radius makes the cached set no longer nearby -> re-fetch. - repository = buildRepository(GeofenceSyncMode.NEARBY) + fun refresh_givenFreshButMovedBeyondFetchRadius_expectRemoteFetchWithLocation() = runTest { + // Even within the freshness window, moving past the fetch radius makes the cached set no + // longer nearby -> re-fetch. every { secureUserStore.getUserId() } returns "user-42" every { store.getLastSyncTimestamp() } returns System.currentTimeMillis() - 60_000L // time-fresh every { store.getCachedConfig() } returns sampleConfig() // remoteFetchRefreshTriggerRadius = 5 km @@ -255,8 +216,7 @@ class GeofenceRepositoryTest : RobolectricTest() { } @Test - fun handleMovement_givenNearbyModeAndMovedBeyondFetchRadius_expectRemoteFetchWithLocation() = runTest { - repository = buildRepository(GeofenceSyncMode.NEARBY) + fun handleMovement_givenMovedBeyondFetchRadius_expectRemoteFetchWithLocation() = runTest { every { secureUserStore.getUserId() } returns "user-42" every { store.getLastApiFetchLocation() } returns GeofenceLocation(0.0, 0.0) every { store.getCachedConfig() } returns sampleConfig() // remoteFetchRefreshTriggerRadius = 5 km @@ -272,8 +232,7 @@ class GeofenceRepositoryTest : RobolectricTest() { } @Test - fun handleMovement_givenNearbyModeAndMovedWithinFetchRadius_expectLocalReRankNoFetch() = runTest { - repository = buildRepository(GeofenceSyncMode.NEARBY) + fun handleMovement_givenMovedWithinFetchRadius_expectLocalReRankNoFetch() = runTest { val cached = listOf(GeofenceRegion("biz-1", 0.0, 0.0, 100f)) every { secureUserStore.getUserId() } returns "user-42" every { store.getLastApiFetchLocation() } returns GeofenceLocation(0.0, 0.0) @@ -291,31 +250,7 @@ class GeofenceRepositoryTest : RobolectricTest() { } @Test - fun refresh_givenFetchAllModeFreshButMovedFar_expectLocalReRankNotRemote() = runTest { - // App killed while the user travelled far, reopened within the freshness window: the cached - // set is still valid (location-independent) but the registered nearest-N must be re-ranked - // for the new location — locally, without a server call. - val cached = listOf(GeofenceRegion("biz-1", 0.0, 0.0, 100f)) - every { secureUserStore.getUserId() } returns "user-42" - every { store.getLastSyncTimestamp() } returns System.currentTimeMillis() - 60_000L - every { store.getCachedConfig() } returns sampleConfig() - every { store.getLastApiFetchLocation() } returns GeofenceLocation(0.0, 0.0) - every { store.getLastMovementTriggerLocation() } returns GeofenceLocation(0.0, 0.0) - every { store.getCachedRegions() } returns cached - every { store.getRegisteredIds() } returns setOf("biz-1") - every { distanceFilter.nearest(any(), any(), any(), any(), any()) } returns cached - coEvery { manager.replaceGeofences(any(), any()) } returns Result.success(Unit) - - // 1° latitude ≈ 111 km — far beyond localRefreshTriggerRadius — but within time freshness. - repository.refresh(latitude = 1.0, longitude = 0.0) - - coVerify(exactly = 0) { apiService.fetchGeofences(any(), any()) } - coVerify { manager.replaceGeofences(any(), any()) } - verify(exactly = 0) { logger.logSyncSkippedFresh() } - } - - @Test - fun refresh_givenFetchAllModeFreshAndNotMoved_expectSkip() = runTest { + fun refresh_givenFreshAndNotMoved_expectSkip() = runTest { every { secureUserStore.getUserId() } returns "user-42" every { store.getLastSyncTimestamp() } returns System.currentTimeMillis() - 60_000L every { store.getCachedConfig() } returns sampleConfig() @@ -332,20 +267,21 @@ class GeofenceRepositoryTest : RobolectricTest() { } @Test - fun refresh_givenFarFromLastFetchButAtLastRegistration_expectSkip() = runTest { + fun refresh_givenPastLocalRadiusFromFetchButAtLastRegistration_expectSkip() = runTest { // Ranking staleness is measured from the last registration (movement-trigger center), NOT the - // last API fetch. The device sits far from a stale fetch anchor but exactly where it was last + // last API fetch. The device sits ~2.2 km from the fetch anchor — beyond the 1 km local radius + // but within the 5 km fetch radius (so no remote re-fetch) — yet exactly where it was last // re-ranked, so the ranking is current → SKIP. Guards against measuring from the fetch anchor. every { secureUserStore.getUserId() } returns "user-42" every { store.getLastSyncTimestamp() } returns System.currentTimeMillis() - 60_000L // time-fresh every { store.getCachedConfig() } returns sampleConfig() // local 1 km, remote 5 km - every { store.getLastApiFetchLocation() } returns GeofenceLocation(0.0, 0.0) // stale fetch anchor - every { store.getLastMovementTriggerLocation() } returns GeofenceLocation(1.0, 0.0) // last re-rank - every { store.getCachedRegions() } returns listOf(GeofenceRegion("biz-1", 1.0, 0.0, 100f)) + every { store.getLastApiFetchLocation() } returns GeofenceLocation(0.0, 0.0) // fetch anchor + every { store.getLastMovementTriggerLocation() } returns GeofenceLocation(0.02, 0.0) // last re-rank + every { store.getCachedRegions() } returns listOf(GeofenceRegion("biz-1", 0.02, 0.0, 100f)) every { store.getRegisteredIds() } returns setOf("biz-1") // regs intact - // At the last-registration point (0 m from it), but ~111 km from the fetch anchor. - repository.refresh(latitude = 1.0, longitude = 0.0) + // At the last-registration point (0 m from it), but ~2.2 km from the fetch anchor. + repository.refresh(latitude = 0.02, longitude = 0.0) coVerify(exactly = 0) { apiService.fetchGeofences(any(), any()) } coVerify(exactly = 0) { manager.replaceGeofences(any(), any()) } diff --git a/geofence/src/test/java/io/customer/geofence/GeofenceSyncModeTest.kt b/geofence/src/test/java/io/customer/geofence/GeofenceSyncModeTest.kt deleted file mode 100644 index 7fa44f5d3..000000000 --- a/geofence/src/test/java/io/customer/geofence/GeofenceSyncModeTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.customer.geofence - -import org.amshove.kluent.shouldBeEqualTo -import org.junit.Test - -class GeofenceSyncModeTest { - - private val config = GeofenceConfig.fallback().copy(remoteFetchRefreshTriggerRadius = 5_000f) - - @Test - fun active_expectNearby() { - // The shipped default. Changing this changes whether the SDK transmits device location. - GeofenceSyncMode.active shouldBeEqualTo GeofenceSyncMode.NEARBY - } - - @Test - fun fetchAll_givenMovedFarPastRadius_expectNoRemoteFetch() { - // Holds the full set, so even moving far past the radius never re-fetches. - GeofenceSyncMode.FETCH_ALL.movementRequiresRemoteFetch(10_000f, config) shouldBeEqualTo false - } - - @Test - fun nearby_givenMovedAtOrBeyondFetchRadius_expectRemoteFetch() { - GeofenceSyncMode.NEARBY.movementRequiresRemoteFetch(5_000f, config) shouldBeEqualTo true - GeofenceSyncMode.NEARBY.movementRequiresRemoteFetch(5_001f, config) shouldBeEqualTo true - } - - @Test - fun nearby_givenWithinFetchRadius_expectNoRemoteFetch() { - GeofenceSyncMode.NEARBY.movementRequiresRemoteFetch(4_999f, config) shouldBeEqualTo false - } -} diff --git a/geofence/src/test/java/io/customer/geofence/api/GeofenceApiServiceTest.kt b/geofence/src/test/java/io/customer/geofence/api/GeofenceApiServiceTest.kt index 693be146e..577b5acb4 100644 --- a/geofence/src/test/java/io/customer/geofence/api/GeofenceApiServiceTest.kt +++ b/geofence/src/test/java/io/customer/geofence/api/GeofenceApiServiceTest.kt @@ -10,7 +10,6 @@ import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeEqualTo -import org.amshove.kluent.shouldBeNull import org.amshove.kluent.shouldContain import org.junit.Test @@ -24,22 +23,12 @@ class GeofenceApiServiceTest { val capturedParams = slot() coEvery { httpClient.request(capture(capturedParams)) } returns Result.success("{}") - service.fetchGeofences(GeofenceLocation(latitude = 1.0, longitude = 2.0)) + service.fetchGeofences(GeofenceLocation(latitude = 1.0, longitude = 2.0), radiusMeters = 1000.0) capturedParams.captured.method shouldBeEqualTo HttpMethod.POST capturedParams.captured.path shouldBeEqualTo "/geofences/nearest" } - @Test - fun fetchGeofences_givenNoLocation_expectNoBody() = runTest { - val capturedParams = slot() - coEvery { httpClient.request(capture(capturedParams)) } returns Result.success("{}") - - service.fetchGeofences(location = null) - - capturedParams.captured.body.shouldBeNull() - } - @Test fun fetchGeofences_givenLocation_expectCoordinatesInJsonBody() = runTest { val capturedParams = slot()