Skip to content

Commit bd4c64d

Browse files
committed
Add support for location-based filtering
1 parent cd0a959 commit bd4c64d

File tree

13 files changed

+586
-4
lines changed

13 files changed

+586
-4
lines changed

stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ package io.getstream.android.core.api.filter
1818

1919
import io.getstream.android.core.annotations.StreamInternalApi
2020
import io.getstream.android.core.annotations.StreamPublishedApi
21+
import io.getstream.android.core.api.model.location.BoundingBox
22+
import io.getstream.android.core.api.model.location.CircularRegion
23+
import io.getstream.android.core.api.model.location.toRequestMap
2124
import io.getstream.android.core.internal.filter.BinaryOperator
2225
import io.getstream.android.core.internal.filter.CollectionOperator
2326
import io.getstream.android.core.internal.filter.FilterOperations
@@ -45,11 +48,19 @@ internal data class CollectionOperationFilter<M, F : FilterField<M>>(
4548
@StreamInternalApi
4649
public fun Filter<*, *>.toRequest(): Map<String, Any> =
4750
when (this) {
48-
is BinaryOperationFilter<*, *> -> mapOf(field.remote to mapOf(operator.remote to value))
51+
is BinaryOperationFilter<*, *> ->
52+
mapOf(field.remote to mapOf(operator.remote to value.toRequestValue()))
4953
is CollectionOperationFilter<*, *> ->
5054
mapOf(operator.remote to filters.map(Filter<*, *>::toRequest))
5155
}
5256

57+
private fun Any.toRequestValue() =
58+
when (this) {
59+
is CircularRegion -> toRequestMap()
60+
is BoundingBox -> toRequestMap()
61+
else -> this
62+
}
63+
5364
/** Checks if this filter matches the given item. */
5465
@StreamInternalApi
5566
public infix fun <M, F : FilterField<M>> Filter<M, F>.matches(item: M): Boolean =
@@ -71,8 +82,10 @@ public infix fun <M, F : FilterField<M>> Filter<M, F>.matches(item: M): Boolean
7182
BinaryOperator.QUERY -> notNull && search(filterValue, where = fieldValue)
7283
BinaryOperator.AUTOCOMPLETE -> notNull && fieldValue autocompletes filterValue
7384
BinaryOperator.EXISTS -> fieldValue exists filterValue
74-
BinaryOperator.CONTAINS -> notNull && fieldValue contains filterValue
85+
BinaryOperator.CONTAINS -> notNull && fieldValue doesContain filterValue
7586
BinaryOperator.PATH_EXISTS -> notNull && fieldValue containsPath filterValue
87+
BinaryOperator.NEAR -> notNull && fieldValue near filterValue
88+
BinaryOperator.WITHIN_BOUNDS -> notNull && fieldValue withinBounds filterValue
7689
}
7790
}
7891
}

stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filters.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
package io.getstream.android.core.api.filter
1818

1919
import io.getstream.android.core.annotations.StreamPublishedApi
20+
import io.getstream.android.core.api.model.location.BoundingBox
21+
import io.getstream.android.core.api.model.location.CircularRegion
2022
import io.getstream.android.core.internal.filter.BinaryOperator
2123
import io.getstream.android.core.internal.filter.CollectionOperator
2224

@@ -171,3 +173,30 @@ public fun <M, F : FilterField<M>> F.contains(value: Any): Filter<M, F> =
171173
@StreamPublishedApi
172174
public fun <M, F : FilterField<M>> F.pathExists(value: String): Filter<M, F> =
173175
BinaryOperationFilter(BinaryOperator.PATH_EXISTS, this, value)
176+
177+
/**
178+
* Creates a filter that uses the Haversine formula to find values within the specified distance
179+
* from the given coordinates.
180+
*
181+
* This filter matches locations that fall within a circular region defined by a center point and a
182+
* radius. The distance calculation uses the Haversine formula to account for Earth's curvature.
183+
*
184+
* @param region The circular region defining the center point and radius to match against.
185+
* @return A filter that matches when the location field is within the specified circular region.
186+
*/
187+
@StreamPublishedApi
188+
public fun <M, F : FilterField<M>> F.near(region: CircularRegion): Filter<M, F> =
189+
BinaryOperationFilter(BinaryOperator.NEAR, this, region)
190+
191+
/**
192+
* Creates a filter that finds values within the specified rectangular bounding box.
193+
*
194+
* This filter matches locations that fall within a rectangular geographic region defined by
195+
* northeast and southwest corner coordinates.
196+
*
197+
* @param boundingBox The bounding box defining the rectangular region to match against.
198+
* @return A filter that matches when the location field is within the specified bounding box.
199+
*/
200+
@StreamPublishedApi
201+
public fun <M, F : FilterField<M>> F.withinBounds(boundingBox: BoundingBox): Filter<M, F> =
202+
BinaryOperationFilter(BinaryOperator.WITHIN_BOUNDS, this, boundingBox)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.android.core.api.model.location
18+
19+
import io.getstream.android.core.annotations.StreamPublishedApi
20+
21+
/**
22+
* A rectangular geographic region defined by northeast and southwest corner coordinates.
23+
*
24+
* @param northeast The northeast (top-right) corner coordinate of the bounding box.
25+
* @param southwest The southwest (bottom-left) corner coordinate of the bounding box.
26+
*/
27+
@StreamPublishedApi
28+
public data class BoundingBox(val northeast: LocationCoordinate, val southwest: LocationCoordinate)
29+
30+
/**
31+
* Checks if the specified coordinate is within this bounding box.
32+
*
33+
* @param coordinate The coordinate to check.
34+
* @return True if the coordinate is within the bounding box, false otherwise.
35+
*/
36+
internal operator fun BoundingBox.contains(coordinate: LocationCoordinate): Boolean {
37+
return coordinate.latitude >= southwest.latitude &&
38+
coordinate.latitude <= northeast.latitude &&
39+
coordinate.longitude >= southwest.longitude &&
40+
coordinate.longitude <= northeast.longitude
41+
}
42+
43+
/** Converts this bounding box to a map representation suitable for API requests. */
44+
internal fun BoundingBox.toRequestMap(): Map<String, Any> =
45+
mapOf(
46+
"ne_lat" to northeast.latitude,
47+
"ne_lng" to northeast.longitude,
48+
"sw_lat" to southwest.latitude,
49+
"sw_lng" to southwest.longitude,
50+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.android.core.api.model.location
18+
19+
import android.location.Location
20+
import io.getstream.android.core.annotations.StreamPublishedApi
21+
22+
/**
23+
* A circular geographic region defined by a center point and a radius.
24+
*
25+
* @param center The center coordinate of the circular region.
26+
* @param radius The radius of the circular region.
27+
*/
28+
@StreamPublishedApi
29+
public data class CircularRegion(val center: LocationCoordinate, val radius: Distance)
30+
31+
/**
32+
* Checks if the specified coordinate is within this circular region.
33+
*
34+
* @param coordinate The coordinate to check.
35+
* @return True if the coordinate is within the region, false otherwise.
36+
*/
37+
internal operator fun CircularRegion.contains(coordinate: LocationCoordinate): Boolean {
38+
val centerLocation =
39+
Location("").apply {
40+
latitude = this@contains.center.latitude
41+
longitude = this@contains.center.longitude
42+
}
43+
val coordinateLocation =
44+
Location("").apply {
45+
latitude = coordinate.latitude
46+
longitude = coordinate.longitude
47+
}
48+
val distance = centerLocation.distanceTo(coordinateLocation).toDouble()
49+
return distance <= this@contains.radius.inMeters
50+
}
51+
52+
/** Converts this circular region to a map representation suitable for API requests. */
53+
internal fun CircularRegion.toRequestMap(): Map<String, Any> =
54+
mapOf("lat" to center.latitude, "lng" to center.longitude, "distance" to radius.inKilometers)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.android.core.api.model.location
18+
19+
import io.getstream.android.core.annotations.StreamPublishedApi
20+
21+
/**
22+
* Represents a distance measurement with type safety and automatic unit conversion.
23+
*
24+
* @param inMeters The distance value stored internally in meters.
25+
*/
26+
@JvmInline
27+
@StreamPublishedApi
28+
public value class Distance private constructor(public val inMeters: Double) {
29+
30+
/** Returns the distance value in kilometers. */
31+
public val inKilometers: Double
32+
get() = inMeters / 1000.0
33+
34+
internal companion object {
35+
fun fromMeters(meters: Double): Distance = Distance(meters)
36+
}
37+
}
38+
39+
/**
40+
* Extension property to create a [Distance] from a [Double] value in meters.
41+
*
42+
* ## Example
43+
*
44+
* ```kotlin
45+
* val distance = 1500.2.meters // 1500.2 meters
46+
* ```
47+
*/
48+
@StreamPublishedApi
49+
public val Double.meters: Distance
50+
get() = Distance.fromMeters(this)
51+
52+
/**
53+
* Extension property to create a [Distance] from a [Double] value in kilometers.
54+
*
55+
* ## Example
56+
*
57+
* ```kotlin
58+
* val distance = 1.5.kilometers // 1.5 kilometers
59+
* ```
60+
*/
61+
@StreamPublishedApi
62+
public val Double.kilometers: Distance
63+
get() = Distance.fromMeters(this * 1000.0)
64+
65+
/**
66+
* Extension property to create a [Distance] from an [Int] value in meters.
67+
*
68+
* ## Example
69+
*
70+
* ```kotlin
71+
* val distance = 1500.meters // 1500 meters
72+
* ```
73+
*/
74+
@StreamPublishedApi
75+
public val Int.meters: Distance
76+
get() = toDouble().meters
77+
78+
/**
79+
* Extension property to create a [Distance] from an [Int] value in kilometers.
80+
*
81+
* ## Example
82+
*
83+
* ```kotlin
84+
* val distance = 5.kilometers // 5 kilometers
85+
* ```
86+
*/
87+
@StreamPublishedApi
88+
public val Int.kilometers: Distance
89+
get() = toDouble().kilometers
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright (c) 2014-2025 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-core-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.android.core.api.model.location
18+
19+
import io.getstream.android.core.annotations.StreamPublishedApi
20+
21+
/**
22+
* Represents a geographic coordinate with latitude and longitude values.
23+
*
24+
* This class is used to represent location data in geo-spatial filtering operations.
25+
*
26+
* @param latitude The latitude coordinate in degrees. Must be between -90 and 90.
27+
* @param longitude The longitude coordinate in degrees. Must be between -180 and 180.
28+
*/
29+
@StreamPublishedApi
30+
public data class LocationCoordinate(val latitude: Double, val longitude: Double)

stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616

1717
package io.getstream.android.core.internal.filter
1818

19+
import io.getstream.android.core.api.model.location.BoundingBox
20+
import io.getstream.android.core.api.model.location.CircularRegion
21+
import io.getstream.android.core.api.model.location.LocationCoordinate
22+
import io.getstream.android.core.api.model.location.contains
23+
import io.getstream.android.core.api.model.location.kilometers
24+
1925
internal object FilterOperations {
2026
infix fun Any.greater(that: Any) = anyCompare(this, that)?.let { it > 0 } == true
2127

@@ -47,7 +53,8 @@ internal object FilterOperations {
4753
else -> false
4854
}
4955

50-
infix fun Any.contains(that: Any): Boolean =
56+
// Not called "contains" to avoid overloading `Any.contains`, which is too broad
57+
infix fun Any.doesContain(that: Any): Boolean =
5158
when {
5259
that `in` this -> true
5360

@@ -57,7 +64,7 @@ internal object FilterOperations {
5764
val thisValue = this[thatKey]
5865

5966
thisValue == thatValue ||
60-
thisValue != null && thatValue != null && thisValue contains thatValue
67+
thisValue != null && thatValue != null && thisValue doesContain thatValue
6168
}
6269
}
6370

@@ -97,4 +104,53 @@ internal object FilterOperations {
97104

98105
return true
99106
}
107+
108+
infix fun Any.near(that: Any): Boolean {
109+
if (this !is LocationCoordinate) return false
110+
111+
return when (that) {
112+
is CircularRegion -> this in that
113+
is Map<*, *> -> {
114+
// Handle map format as expected by the API:
115+
// { "lat": 41.8904, "lng": 12.4922, "distance": 5.0 }
116+
val lat = (that["lat"] as? Number)?.toDouble() ?: return false
117+
val lng = (that["lng"] as? Number)?.toDouble() ?: return false
118+
val distanceKm = (that["distance"] as? Number)?.toDouble() ?: return false
119+
120+
val region =
121+
CircularRegion(
122+
center = LocationCoordinate(lat, lng),
123+
radius = distanceKm.kilometers,
124+
)
125+
this in region
126+
}
127+
128+
else -> false
129+
}
130+
}
131+
132+
infix fun Any.withinBounds(that: Any): Boolean {
133+
if (this !is LocationCoordinate) return false
134+
135+
return when (that) {
136+
is BoundingBox -> this in that
137+
is Map<*, *> -> {
138+
// Handle map format as expected by the API:
139+
// { "ne_lat": 41.9200, "ne_lng": 12.5200, "sw_lat": 41.8800, "sw_lng": 12.4700 }
140+
val neLat = (that["ne_lat"] as? Number)?.toDouble() ?: return false
141+
val neLng = (that["ne_lng"] as? Number)?.toDouble() ?: return false
142+
val swLat = (that["sw_lat"] as? Number)?.toDouble() ?: return false
143+
val swLng = (that["sw_lng"] as? Number)?.toDouble() ?: return false
144+
145+
val boundingBox =
146+
BoundingBox(
147+
northeast = LocationCoordinate(neLat, neLng),
148+
southwest = LocationCoordinate(swLat, swLng),
149+
)
150+
this in boundingBox
151+
}
152+
153+
else -> false
154+
}
155+
}
100156
}

0 commit comments

Comments
 (0)