Skip to content

Commit 50c3431

Browse files
gpuntoVelikovPetar
andauthored
Add support for location-based filtering (#30)
Co-authored-by: Petar Velikov <[email protected]>
1 parent f6ff2c1 commit 50c3431

File tree

9 files changed

+437
-5
lines changed

9 files changed

+437
-5
lines changed

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

Lines changed: 15 additions & 3 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,20 @@ 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()))
53+
4954
is CollectionOperationFilter<*, *> ->
5055
mapOf(operator.remote to filters.map(Filter<*, *>::toRequest))
5156
}
5257

58+
private fun Any.toRequestValue() =
59+
when (this) {
60+
is CircularRegion -> toRequestMap()
61+
is BoundingBox -> toRequestMap()
62+
else -> this
63+
}
64+
5365
/** Checks if this filter matches the given item. */
5466
@StreamInternalApi
5567
public infix fun <M, F : FilterField<M>> Filter<M, F>.matches(item: M): Boolean =
@@ -61,7 +73,7 @@ public infix fun <M, F : FilterField<M>> Filter<M, F>.matches(item: M): Boolean
6173

6274
with(FilterOperations) {
6375
when (operator) {
64-
BinaryOperator.EQUAL -> notNull && fieldValue == filterValue
76+
BinaryOperator.EQUAL -> notNull && fieldValue equal filterValue
6577
BinaryOperator.GREATER -> notNull && fieldValue greater filterValue
6678
BinaryOperator.LESS -> notNull && fieldValue less filterValue
6779
BinaryOperator.GREATER_OR_EQUAL ->
@@ -71,7 +83,7 @@ public infix fun <M, F : FilterField<M>> Filter<M, F>.matches(item: M): Boolean
7183
BinaryOperator.QUERY -> notNull && search(filterValue, where = fieldValue)
7284
BinaryOperator.AUTOCOMPLETE -> notNull && fieldValue autocompletes filterValue
7385
BinaryOperator.EXISTS -> fieldValue exists filterValue
74-
BinaryOperator.CONTAINS -> notNull && fieldValue contains filterValue
86+
BinaryOperator.CONTAINS -> notNull && fieldValue doesContain filterValue
7587
BinaryOperator.PATH_EXISTS -> notNull && fieldValue containsPath filterValue
7688
}
7789
}
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,7 +16,15 @@
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 {
26+
infix fun Any.equal(that: Any) = this == that || isNear(that) || isWithinBoundsOf(that)
27+
2028
infix fun Any.greater(that: Any) = anyCompare(this, that)?.let { it > 0 } == true
2129

2230
infix fun Any.greaterOrEqual(that: Any) = anyCompare(this, that)?.let { it >= 0 } == true
@@ -47,7 +55,8 @@ internal object FilterOperations {
4755
else -> false
4856
}
4957

50-
infix fun Any.contains(that: Any): Boolean =
58+
// Not called "contains" to avoid overloading `Any.contains`, which is too broad
59+
infix fun Any.doesContain(that: Any): Boolean =
5160
when {
5261
that `in` this -> true
5362

@@ -57,7 +66,7 @@ internal object FilterOperations {
5766
val thisValue = this[thatKey]
5867

5968
thisValue == thatValue ||
60-
thisValue != null && thatValue != null && thisValue contains thatValue
69+
thisValue != null && thatValue != null && thisValue doesContain thatValue
6170
}
6271
}
6372

@@ -97,4 +106,51 @@ internal object FilterOperations {
97106

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

0 commit comments

Comments
 (0)