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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -216,11 +219,11 @@ internal class GeofenceRepositoryImpl(
latitude: Double,
longitude: Double
): Result<Unit> {
// 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(
Expand Down

This file was deleted.

10 changes: 10 additions & 0 deletions geofence/src/main/kotlin/io/customer/geofence/RefreshAction.kt
Original file line number Diff line number Diff line change
@@ -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 }
Original file line number Diff line number Diff line change
Expand Up @@ -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<GeofenceApiResponse>
}

Expand All @@ -27,16 +25,14 @@ internal class GeofenceApiServiceImpl(
) : GeofenceApiService {

override suspend fun fetchGeofences(
location: GeofenceLocation?,
location: GeofenceLocation,
radiusMeters: Double
): Result<GeofenceApiResponse> {
// `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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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()) }
Expand Down
Loading
Loading