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 @@ -56,14 +56,23 @@ class PendingDeliveryStore<T : PendingDeliveryStore.PendingDeliveryEntry>(
private val listSerializer = ListSerializer(elementSerializer)

/** Append a new entry, evicting the head if the store is at capacity. */
fun append(entry: T) {
fun append(entry: T) = appendAll(listOf(entry))

/**
* Append entries in one read-modify-write so a batch (e.g. a transition's per-geoset fan-out)
* persists atomically — a crash can't save some and lose the rest. Evicts from the head when
* over capacity. Named distinctly from [append] so a single-arg `append(x)` never resolves
* ambiguously against this overload (e.g. in mocked `append(any())` verifications).
*/
fun appendAll(entries: List<T>) {
if (entries.isEmpty()) return
lock.withLock {
val entries = readAll().toMutableList()
entries.add(entry)
while (entries.size > maxEntries) {
entries.removeAt(0)
val all = readAll().toMutableList()
all.addAll(entries)
while (all.size > maxEntries) {
all.removeAt(0)
}
writeAll(entries)
writeAll(all)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,35 @@ class PendingDeliveryStoreTest : RobolectricTest() {
store.loadAll().map { it.id } shouldBeEqualTo ids
}

@Test
fun appendAll_givenMultipleEntries_expectAllPersistedAfterExisting() {
val store = newStore()
store.append(entry("a"))

store.appendAll(listOf(entry("b"), entry("c")))

store.loadAll().map { it.id } shouldBeEqualTo listOf("a", "b", "c")
}

@Test
fun appendAll_givenEmpty_expectNoChange() {
val store = newStore()
store.append(entry("a"))

store.appendAll(emptyList())

store.loadAll().map { it.id } shouldBeEqualTo listOf("a")
}

@Test
fun appendAll_givenOverCapacity_expectOldestEvicted() {
val store = newStore(maxEntries = 2)

store.appendAll(listOf(entry("a"), entry("b"), entry("c")))

store.loadAll().map { it.id } shouldBeEqualTo listOf("b", "c")
}

@Test
fun remove_givenExistingKey_expectEntryRemoved() {
val store = newStore()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,34 +135,47 @@ class GeofenceBroadcastReceiver : BroadcastReceiver() {
}
logger.logTransitionEmitting(geofenceId, transition.name)

// Record the transition durably before scheduling. Two channels then
// deliver it at least once, deduped downstream by transitionId: the
// WorkManager worker (direct HTTP, survives process death) and the
// foreground flush (analytics pipeline). Append first so a WorkManager
// scheduling failure still leaves the entry for the flush; isolate the
// scheduler so it can't abandon the rest of the batch.
// Snapshot userId so a sign-out + sign-in before delivery can't
// reattribute this transition. Empty userId is treated as "not
// identified" per the SDK's `isUserIdentified` convention.
val entry = PendingGeofenceDelivery(
geofenceId = geofenceId,
transition = transition,
timestamp = timestamp,
userId = androidComponent.secureUserStore.getUserId()?.takeIf { it.isNotEmpty() },
// Minted once here so every delivery attempt for this transition carries the same id.
transitionId = UUID.randomUUID().toString(),
geofenceName = androidComponent.geofenceRegionStore.getCachedRegionName(geofenceId)
)
androidComponent.pendingGeofenceDeliveryStore.append(entry)
// Anonymous entries can only be delivered via the foreground flush —
// skip the WorkManager schedule that would just no-op on null userId.
if (entry.userId != null) {
try {
scheduler.schedule(entry)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.logSchedulerFailed(geofenceId, transition.name, e.message)
// Snapshot userId so a sign-out + sign-in before delivery can't reattribute this
// transition. Empty userId is treated as "not identified" per `isUserIdentified`.
val userId = androidComponent.secureUserStore.getUserId()?.takeIf { it.isNotEmpty() }
// One transitionId for the whole crossing, shared across the per-geoset fan-out below.
val transitionId = UUID.randomUUID().toString()
val cachedRegion = androidComponent.geofenceRegionStore.getCachedRegion(geofenceId)
val geofenceName = cachedRegion?.name?.takeIf { it.isNotEmpty() }
// One event per geoset; a fence with no geosets still emits one (null geoset) so a real
// OS transition is never dropped. Distinct so a fence listing the same geoset twice
// doesn't fan out to duplicate events.
val geosetIds: List<String?> = cachedRegion?.geosetIds?.distinct()?.takeIf { it.isNotEmpty() } ?: listOf(null)
val entries = geosetIds.map { geosetId ->
PendingGeofenceDelivery(
geofenceId = geofenceId,
transition = transition,
timestamp = timestamp,
userId = userId,
transitionId = transitionId,
geofenceName = geofenceName,
geosetId = geosetId
)
}

// Persist the whole fan-out in one atomic write before any send, so an app kill
// mid-batch can't save some geosets and lose the rest (the cooldown is already spent,
// so a lost row would never retry). Two channels then deliver each entry at least once,
// deduped downstream by (transitionId, geoset): the WorkManager worker (direct HTTP,
// survives process death) and the foreground flush (analytics pipeline).
androidComponent.pendingGeofenceDeliveryStore.appendAll(entries)
entries.forEach { entry ->
// Anonymous entries can only be delivered via the foreground flush —
// skip the WorkManager schedule that would just no-op on null userId.
// Isolate the scheduler so one failure can't abandon the rest of the batch.
if (entry.userId != null) {
try {
scheduler.schedule(entry)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.logSchedulerFailed(geofenceId, transition.name, e.message)
}
}
}
}
Expand Down
13 changes: 13 additions & 0 deletions geofence/src/main/kotlin/io/customer/geofence/GeofenceConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,16 @@ internal data class GeofenceConfig(
)
}
}

/**
* Radius (m) for the remote geofence fetch: the max of the monitoring cap and the re-fetch radius,
* so the set covers both what we'll monitor and everywhere the device can reach before the next
* re-fetch. A disabled cap (`Float.MAX_VALUE`) collapses to the finite fallback. Operates on the
* already-coerced config, so the value is always in a sane range.
*/
internal fun GeofenceConfig.remoteSearchRadiusMeters(): Double {
val cap = maxMonitoringDistance.takeIf {
it != GeofenceConstants.NO_MONITORING_DISTANCE_CAP_METERS
} ?: GeofenceConstants.FALLBACK_MAX_MONITORING_DISTANCE_METERS
return maxOf(cap, remoteFetchRefreshTriggerRadius).toDouble()
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ internal data class GeofenceRegion(
GeofenceTransitionType.EXIT
),
@SerialName("lastUpdated")
val lastUpdated: Long = 0L
val lastUpdated: Long = 0L,
@SerialName("geosetIds")
val geosetIds: List<String> = emptyList()
)

/** Transition types a geofence can monitor, mapped to GMS constants. */
Expand Down Expand Up @@ -66,3 +68,10 @@ internal fun GeofenceRegion.toGmsTransitionTypes(): Int {
transitionTypes.forEach { mask = mask or it.gmsValue }
return mask
}

/**
* Equal for OS-registration purposes: everything but [geosetIds], which drive event fan-out, not
* GMS registration. A geoset-only change updates the cache without a needless re-register.
*/
internal fun GeofenceRegion.equalsIgnoringGeosetIds(other: GeofenceRegion): Boolean =
copy(geosetIds = other.geosetIds) == other
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import io.customer.geofence.api.GeofenceApiService
import io.customer.geofence.api.toDomainConfig
import io.customer.geofence.api.toDomainRegions
import io.customer.geofence.store.GeofenceRegionStore
import io.customer.geofence.store.getCachedConfigOrFallback
import io.customer.location.LocationCoordinates
import io.customer.sdk.core.util.Clock
import io.customer.sdk.data.store.SecureUserStore
Expand Down Expand Up @@ -84,7 +85,7 @@ internal class GeofenceRepositoryImpl(
return Result.success(Unit)
}

val config = store.getCachedConfig() ?: GeofenceConfig.fallback()
val config = store.getCachedConfigOrFallback()
return when (refreshAction(LocationCoordinates(latitude, longitude), config)) {
RefreshAction.REMOTE -> performRemoteRefresh(userId, latitude, longitude)
RefreshAction.LOCAL -> performLocalRefresh(userId, latitude, longitude, config)
Expand Down Expand Up @@ -159,7 +160,7 @@ internal class GeofenceRepositoryImpl(
}

val anchor = store.getLastApiFetchLocation()
val config = store.getCachedConfig() ?: GeofenceConfig.fallback()
val config = store.getCachedConfigOrFallback()
val distanceFromAnchor = anchor?.distanceTo(latitude, longitude) ?: 0f
// 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.
Expand Down Expand Up @@ -199,7 +200,7 @@ internal class GeofenceRepositoryImpl(
logger.logSyncSkipped("no cached state to restore")
return Result.success(Unit)
}
val cachedConfig = store.getCachedConfig() ?: GeofenceConfig.fallback()
val cachedConfig = store.getCachedConfigOrFallback()
return performLocalRefresh(
userId = userId,
latitude = effectiveLocation.latitude,
Expand All @@ -217,16 +218,17 @@ internal class GeofenceRepositoryImpl(
): 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
val fetchResult = apiService.fetchGeofences(fetchLocation)
val requestRadiusMeters = store.getCachedConfigOrFallback().remoteSearchRadiusMeters()
val fetchResult = apiService.fetchGeofences(fetchLocation, requestRadiusMeters)
return fetchResult.fold(
onSuccess = { response ->
val regions = response.toDomainRegions()
// Config preference: server-shipped > last cached > constants.
val parsedConfig = response.toDomainConfig()
val config = parsedConfig
?: store.getCachedConfig()
?: GeofenceConfig.fallback()
val config = parsedConfig ?: store.getCachedConfigOrFallback()
registerNearestAndPersist(
userId = userId,
latitude = latitude,
Expand Down Expand Up @@ -283,7 +285,10 @@ internal class GeofenceRepositoryImpl(
val registeredBusinessIds = store.getRegisteredIds() - GeofenceConstants.MOVEMENT_TRIGGER_ID
val cachedById = store.getCachedRegions().associateBy { it.id }
regions
.filter { it.id in registeredBusinessIds && cachedById[it.id] == it }
.filter { region ->
val cached = cachedById[region.id]
region.id in registeredBusinessIds && cached?.equalsIgnoringGeosetIds(region) == true
}
.map { it.id }
.toSet()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Wire shape of `GET /v1/geofences/nearby`. Backend ships only `geofences`
* today; `config` and per-region `transition_types` / `last_updated` are
* nullable forward-compat slots the SDK will honor when backend adds them.
* Wire shape of `POST /geofences/nearest`. `config` and the per-region
* `name` / `transition_types` / `last_updated` fields are optional forward-compat
* slots the SDK honors if the backend sends them and silently skips otherwise.
*/
@Serializable
internal data class GeofenceApiResponse(
Expand Down Expand Up @@ -64,7 +64,9 @@ internal data class GeofenceApiRegion(
@SerialName("transition_types")
val transitionTypes: List<String>? = null,
@SerialName("last_updated")
val lastUpdated: Long? = null
val lastUpdated: Long? = null,
@SerialName("geoset_ids")
val geosetIds: List<String> = emptyList()
)

/** Returns `null` when backend didn't send a `config` block — gates the cache save. */
Expand Down Expand Up @@ -124,7 +126,8 @@ private fun GeofenceApiRegion.toDomain(): GeofenceRegion = GeofenceRegion(
longitude = longitude,
radius = radius.toFloat(),
transitionTypes = resolveTransitionTypes(transitionTypes),
lastUpdated = lastUpdated ?: 0L
lastUpdated = lastUpdated ?: 0L,
geosetIds = geosetIds
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,43 @@ import io.customer.geofence.GeofenceLocation
import io.customer.sdk.core.network.CustomerIOHttpClient
import io.customer.sdk.core.network.HttpMethod
import io.customer.sdk.core.network.HttpRequestParams
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

internal interface GeofenceApiService {
/**
* Fetches geofences. When [location] is null (FETCH_ALL) the backend returns the full (capped)
* set and no location is sent. When non-null (NEARBY) the location is sent so the backend
* returns the nearby set. The request carries no user identity, so the location is not
* attributable to a user.
* 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.
*/
suspend fun fetchGeofences(location: GeofenceLocation? = null): Result<GeofenceApiResponse>
suspend fun fetchGeofences(
location: GeofenceLocation? = null,
radiusMeters: Double = 0.0
): Result<GeofenceApiResponse>
}

internal class GeofenceApiServiceImpl(
private val httpClient: CustomerIOHttpClient,
private val jsonSerializer: GeofenceJsonSerializer
) : GeofenceApiService {

override suspend fun fetchGeofences(location: GeofenceLocation?): Result<GeofenceApiResponse> {
val queryParams = location
?.let { mapOf("latitude" to it.latitude.toString(), "longitude" to it.longitude.toString()) }
?: emptyMap()
override suspend fun fetchGeofences(
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 params = HttpRequestParams(
path = ENDPOINT_PATH,
method = HttpMethod.GET,
queryParams = queryParams
method = HttpMethod.POST,
headers = mapOf("Content-Type" to "application/json"),
body = body
)
return httpClient.request(params).mapCatching { responseBody ->
// Lenient at the wire boundary so the SDK doesn't pin a specific
Expand All @@ -38,6 +51,17 @@ internal class GeofenceApiServiceImpl(
}

private companion object {
private const val ENDPOINT_PATH = "/geofences/nearby"
private const val ENDPOINT_PATH = "/geofences/nearest"
}
}

/** Wire shape of the `POST /geofences/nearest` request body. */
@Serializable
private data class GeofenceNearestRequest(
@SerialName("latitude")
val latitude: Double,
@SerialName("longitude")
val longitude: Double,
@SerialName("radius")
val radius: Double
)
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import kotlinx.serialization.builtins.serializer
* closer to the user's real position than the anchor.
* lastSyncTimestamp — freshness throttle; cleared so the next login re-fetches.
*
* Rationale: backend `/v1/geofences/nearby` is workspace-scoped (no userId on
* Rationale: the backend geofence fetch is workspace-scoped (no userId on
* the wire), so cached regions/config stay valid for any user in the workspace
* and are kept. Dropping the freshness timestamp makes the next login re-fetch
* instead of riding the prior session's window. If backend ever adds per-user
Expand All @@ -50,8 +50,8 @@ internal interface GeofenceRegionStore {
fun saveCachedRegions(regions: List<GeofenceRegion>)
fun getCachedRegions(): List<GeofenceRegion>

/** Name of the cached region with [id], or null if it isn't cached. */
fun getCachedRegionName(id: String): String? = getCachedRegions().find { it.id == id }?.name
/** The cached region with [id], or null if it isn't cached. */
fun getCachedRegion(id: String): GeofenceRegion? = getCachedRegions().find { it.id == id }

fun saveRegisteredIds(ids: Set<String>)
fun getRegisteredIds(): Set<String>
Expand Down Expand Up @@ -83,6 +83,10 @@ internal interface GeofenceRegionStore {
fun clearAll()
}

/** Cached config, or the constant fallback when none is cached. */
internal fun GeofenceRegionStore.getCachedConfigOrFallback(): GeofenceConfig =
getCachedConfig() ?: GeofenceConfig.fallback()

internal class GeofenceRegionStoreImpl(
context: Context,
private val jsonSerializer: GeofenceJsonSerializer,
Expand Down
Loading
Loading