Skip to content

Commit 9289989

Browse files
committed
wip: working mvp 1
1 parent e672dcc commit 9289989

File tree

16 files changed

+993
-2
lines changed

16 files changed

+993
-2
lines changed

location/api/location.api

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ public final class io/customer/location/LocationModuleConfig$Builder : io/custom
1111
}
1212

1313
public abstract interface class io/customer/location/LocationServices {
14+
public abstract fun getGeofenceServices ()Lio/customer/location/geofence/GeofenceServices;
1415
public abstract fun requestLocationUpdate ()V
1516
public abstract fun setLastKnownLocation (DD)V
1617
public abstract fun setLastKnownLocation (Landroid/location/Location;)V
@@ -43,3 +44,39 @@ public final class io/customer/location/ModuleLocation$Companion {
4344
public final fun instance ()Lio/customer/location/ModuleLocation;
4445
}
4546

47+
public final class io/customer/location/geofence/GeofenceBroadcastReceiver : android/content/BroadcastReceiver {
48+
public fun <init> ()V
49+
public fun onReceive (Landroid/content/Context;Landroid/content/Intent;)V
50+
}
51+
52+
public final class io/customer/location/geofence/GeofenceRegion {
53+
public fun <init> (Ljava/lang/String;DDDLjava/lang/String;Ljava/util/Map;J)V
54+
public synthetic fun <init> (Ljava/lang/String;DDDLjava/lang/String;Ljava/util/Map;JILkotlin/jvm/internal/DefaultConstructorMarker;)V
55+
public final fun component1 ()Ljava/lang/String;
56+
public final fun component2 ()D
57+
public final fun component3 ()D
58+
public final fun component4 ()D
59+
public final fun component5 ()Ljava/lang/String;
60+
public final fun component6 ()Ljava/util/Map;
61+
public final fun component7 ()J
62+
public final fun copy (Ljava/lang/String;DDDLjava/lang/String;Ljava/util/Map;J)Lio/customer/location/geofence/GeofenceRegion;
63+
public static synthetic fun copy$default (Lio/customer/location/geofence/GeofenceRegion;Ljava/lang/String;DDDLjava/lang/String;Ljava/util/Map;JILjava/lang/Object;)Lio/customer/location/geofence/GeofenceRegion;
64+
public fun equals (Ljava/lang/Object;)Z
65+
public final fun getCustomData ()Ljava/util/Map;
66+
public final fun getDwellTimeMs ()J
67+
public final fun getId ()Ljava/lang/String;
68+
public final fun getLatitude ()D
69+
public final fun getLongitude ()D
70+
public final fun getName ()Ljava/lang/String;
71+
public final fun getRadius ()D
72+
public fun hashCode ()I
73+
public fun toString ()Ljava/lang/String;
74+
}
75+
76+
public abstract interface class io/customer/location/geofence/GeofenceServices {
77+
public abstract fun addGeofences (Ljava/util/List;)V
78+
public abstract fun getActiveGeofences ()Ljava/util/List;
79+
public abstract fun removeAllGeofences ()V
80+
public abstract fun removeGeofences (Ljava/util/List;)V
81+
}
82+

location/src/main/AndroidManifest.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,16 @@
44
<!-- Coarse location is sufficient for city/timezone level tracking -->
55
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
66

7+
<application>
8+
<!-- BroadcastReceiver for geofence transitions -->
9+
<receiver
10+
android:name="io.customer.location.geofence.GeofenceBroadcastReceiver"
11+
android:enabled="true"
12+
android:exported="false">
13+
<intent-filter>
14+
<action android:name="io.customer.location.GEOFENCE_TRANSITION" />
15+
</intent-filter>
16+
</receiver>
17+
</application>
18+
719
</manifest>

location/src/main/kotlin/io/customer/location/LocationServices.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.customer.location
22

33
import android.location.Location
4+
import io.customer.location.geofence.GeofenceServices
45

56
/**
67
* Public API for the Location module.
@@ -15,9 +16,20 @@ import android.location.Location
1516
*
1617
* // Or pass an Android Location object
1718
* ModuleLocation.instance().locationServices.setLastKnownLocation(androidLocation)
19+
*
20+
* // Access geofencing services
21+
* val geofenceServices = ModuleLocation.instance().locationServices.geofenceServices
1822
* ```
1923
*/
2024
interface LocationServices {
25+
/**
26+
* Access to geofencing services.
27+
*
28+
* Requires location tracking to be enabled (trackingMode != OFF).
29+
* The host app must request and obtain location permissions before using geofencing.
30+
*/
31+
val geofenceServices: GeofenceServices
32+
2133
/**
2234
* Sets the last known location from the host app's existing location system.
2335
*

location/src/main/kotlin/io/customer/location/LocationServicesImpl.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.customer.location
22

33
import android.location.Location
4+
import io.customer.location.geofence.GeofenceServices
45
import io.customer.sdk.core.util.Logger
56
import kotlinx.coroutines.CoroutineScope
67
import kotlinx.coroutines.Job
@@ -16,7 +17,8 @@ internal class LocationServicesImpl(
1617
private val logger: Logger,
1718
private val locationTracker: LocationTracker,
1819
private val orchestrator: LocationOrchestrator,
19-
private val scope: CoroutineScope
20+
private val scope: CoroutineScope,
21+
override val geofenceServices: GeofenceServices
2022
) : LocationServices {
2123

2224
@Volatile

location/src/main/kotlin/io/customer/location/ModuleLocation.kt

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ package io.customer.location
22

33
import android.location.Location
44
import androidx.lifecycle.ProcessLifecycleOwner
5+
import io.customer.location.geofence.GeofenceManager
6+
import io.customer.location.geofence.GeofencePreferenceStore
7+
import io.customer.location.geofence.GeofenceServices
8+
import io.customer.location.geofence.GeofenceServicesImpl
59
import io.customer.location.provider.FusedLocationProvider
610
import io.customer.location.store.LocationPreferenceStoreImpl
711
import io.customer.location.sync.LocationSyncFilter
@@ -23,6 +27,7 @@ import io.customer.sdk.core.util.MainThreadPoster
2327
* - Manual location setting from host app's existing location system
2428
* - One-shot SDK-managed location capture
2529
* - Automatic location capture on app start (ON_APP_START mode)
30+
* - Geofencing for monitoring geographic regions
2631
*
2732
* Usage:
2833
* ```
@@ -43,6 +48,9 @@ import io.customer.sdk.core.util.MainThreadPoster
4348
*
4449
* // SDK-managed one-shot location
4550
* ModuleLocation.instance().locationServices.requestLocationUpdate()
51+
*
52+
* // Geofencing
53+
* ModuleLocation.instance().locationServices.geofenceServices.addGeofences(regions)
4654
* ```
4755
*/
4856
class ModuleLocation @JvmOverloads constructor(
@@ -88,12 +96,23 @@ class ModuleLocation @JvmOverloads constructor(
8896
locationProvider = locationProvider
8997
)
9098

99+
// Initialize geofencing components
100+
val geofenceManager = GeofenceManager(context, logger)
101+
val geofencePreferenceStore = GeofencePreferenceStore(context, logger)
102+
val geofenceServices = GeofenceServicesImpl(
103+
isEnabled = moduleConfig.isEnabled,
104+
geofenceManager = geofenceManager,
105+
preferenceStore = geofencePreferenceStore,
106+
logger = logger
107+
)
108+
91109
_locationServices = LocationServicesImpl(
92110
config = moduleConfig,
93111
logger = logger,
94112
locationTracker = locationTracker,
95113
orchestrator = orchestrator,
96-
scope = locationScope
114+
scope = locationScope,
115+
geofenceServices = geofenceServices
97116
)
98117

99118
// When OFF, skip all background machinery — no restoration, no enrichment,
@@ -162,9 +181,34 @@ private class UninitializedLocationServices(
162181
logger.error("Location module is not initialized. Call CustomerIO.initialize() with ModuleLocation before using location APIs.")
163182
}
164183

184+
override val geofenceServices: GeofenceServices = UninitializedGeofenceServices(logger)
185+
165186
override fun setLastKnownLocation(latitude: Double, longitude: Double) = logNotInitialized()
166187

167188
override fun setLastKnownLocation(location: Location) = logNotInitialized()
168189

169190
override fun requestLocationUpdate() = logNotInitialized()
170191
}
192+
193+
/**
194+
* No-op fallback for geofence services when module is not initialized.
195+
*/
196+
private class UninitializedGeofenceServices(
197+
private val logger: Logger
198+
) : GeofenceServices {
199+
200+
private fun logNotInitialized() {
201+
logger.error("Location module is not initialized. Call CustomerIO.initialize() with ModuleLocation before using geofence APIs.")
202+
}
203+
204+
override fun addGeofences(regions: List<io.customer.location.geofence.GeofenceRegion>) = logNotInitialized()
205+
206+
override fun removeGeofences(ids: List<String>) = logNotInitialized()
207+
208+
override fun removeAllGeofences() = logNotInitialized()
209+
210+
override fun getActiveGeofences(): List<io.customer.location.geofence.GeofenceRegion> {
211+
logNotInitialized()
212+
return emptyList()
213+
}
214+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package io.customer.location.geofence
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.location.Location
7+
import com.google.android.gms.location.Geofence
8+
import com.google.android.gms.location.GeofencingEvent
9+
import io.customer.location.ModuleLocation
10+
import io.customer.sdk.core.di.SDKComponent
11+
import io.customer.sdk.core.pipeline.DataPipeline
12+
import io.customer.sdk.core.util.Logger
13+
14+
/**
15+
* BroadcastReceiver that handles geofence transition events from Android.
16+
* Sends corresponding events to Customer.io when geofences are entered, exited, or dwelled.
17+
*/
18+
class GeofenceBroadcastReceiver : BroadcastReceiver() {
19+
20+
override fun onReceive(context: Context, intent: Intent) {
21+
val geofencingEvent = GeofencingEvent.fromIntent(intent) ?: return
22+
val logger = SDKComponent.logger
23+
24+
if (geofencingEvent.hasError()) {
25+
logger.error("Geofence error: ${geofencingEvent.errorCode}")
26+
return
27+
}
28+
29+
val transitionType = geofencingEvent.geofenceTransition
30+
val triggeringGeofences = geofencingEvent.triggeringGeofences ?: return
31+
val triggeringLocation = geofencingEvent.triggeringLocation
32+
33+
// Get the module instance to access geofence metadata
34+
val geofenceServices = try {
35+
ModuleLocation.instance().locationServices.geofenceServices as? GeofenceServicesImpl
36+
} catch (e: Exception) {
37+
logger.error("Failed to get geofence services: ${e.message}")
38+
null
39+
}
40+
41+
val dataPipeline = SDKComponent.getOrNull<DataPipeline>()
42+
if (dataPipeline == null) {
43+
logger.debug("DataPipeline not available, skipping geofence event")
44+
return
45+
}
46+
47+
if (!dataPipeline.isUserIdentified) {
48+
logger.debug("User not identified, skipping geofence event")
49+
return
50+
}
51+
52+
triggeringGeofences.forEach { geofence ->
53+
val region = geofenceServices?.getGeofenceById(geofence.requestId)
54+
sendGeofenceEvent(dataPipeline, logger, geofence, transitionType, region, triggeringLocation)
55+
}
56+
}
57+
58+
private fun sendGeofenceEvent(
59+
dataPipeline: DataPipeline,
60+
logger: Logger,
61+
geofence: Geofence,
62+
transitionType: Int,
63+
region: GeofenceRegion?,
64+
triggeringLocation: Location?
65+
) {
66+
val eventName = when (transitionType) {
67+
Geofence.GEOFENCE_TRANSITION_ENTER -> GeofenceConstants.EVENT_GEOFENCE_ENTERED
68+
Geofence.GEOFENCE_TRANSITION_EXIT -> GeofenceConstants.EVENT_GEOFENCE_EXITED
69+
Geofence.GEOFENCE_TRANSITION_DWELL -> GeofenceConstants.EVENT_GEOFENCE_DWELLED
70+
else -> {
71+
logger.debug("Unknown geofence transition type: $transitionType")
72+
return
73+
}
74+
}
75+
76+
val transitionName = when (transitionType) {
77+
Geofence.GEOFENCE_TRANSITION_ENTER -> "enter"
78+
Geofence.GEOFENCE_TRANSITION_EXIT -> "exit"
79+
Geofence.GEOFENCE_TRANSITION_DWELL -> "dwell"
80+
else -> "unknown"
81+
}
82+
83+
val properties = mutableMapOf<String, Any>(
84+
"geofence_id" to geofence.requestId,
85+
"transition_type" to transitionName
86+
)
87+
88+
// Add region metadata if available
89+
region?.let {
90+
properties["latitude"] = it.latitude
91+
properties["longitude"] = it.longitude
92+
properties["radius"] = it.radius
93+
it.name?.let { name -> properties["geofence_name"] = name }
94+
it.customData?.let { customData -> properties.putAll(customData) }
95+
96+
// Calculate distance from triggering location to geofence center
97+
if (triggeringLocation != null) {
98+
val geofenceCenter = Location("").apply {
99+
latitude = it.latitude
100+
longitude = it.longitude
101+
}
102+
val distance = triggeringLocation.distanceTo(geofenceCenter)
103+
properties["distance"] = distance.toDouble() // in meters
104+
}
105+
}
106+
107+
logger.debug("Sending geofence event: $eventName for ${geofence.requestId}")
108+
dataPipeline.track(
109+
name = eventName,
110+
properties = properties
111+
)
112+
}
113+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package io.customer.location.geofence
2+
3+
/**
4+
* Constants for geofencing functionality.
5+
*/
6+
internal object GeofenceConstants {
7+
/**
8+
* Event names sent to Customer.io for geofence transitions.
9+
*/
10+
const val EVENT_GEOFENCE_ENTERED = "Geofence Entered"
11+
const val EVENT_GEOFENCE_EXITED = "Geofence Exited"
12+
const val EVENT_GEOFENCE_DWELLED = "Geofence Dwelled"
13+
14+
/**
15+
* Default dwell time in milliseconds (10 minutes).
16+
*/
17+
const val DEFAULT_DWELL_TIME_MS = 10 * 60 * 1000L
18+
19+
/**
20+
* Default geofence expiration (never expires).
21+
*/
22+
const val GEOFENCE_EXPIRATION_NEVER = -1L
23+
24+
/**
25+
* Default geofence radius in meters if not specified.
26+
*/
27+
const val DEFAULT_RADIUS_METERS = 100.0
28+
29+
/**
30+
* Action for geofence transition broadcast.
31+
*/
32+
const val GEOFENCE_TRANSITION_ACTION = "io.customer.location.GEOFENCE_TRANSITION"
33+
34+
/**
35+
* Preference store key for persisted geofences.
36+
*/
37+
const val PREF_KEY_GEOFENCES = "io.customer.location.geofences"
38+
}

0 commit comments

Comments
 (0)