diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt index 4c43570..f3d070f 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/filter/Filter.kt @@ -18,6 +18,9 @@ package io.getstream.android.core.api.filter import io.getstream.android.core.annotations.StreamInternalApi import io.getstream.android.core.annotations.StreamPublishedApi +import io.getstream.android.core.api.model.location.BoundingBox +import io.getstream.android.core.api.model.location.CircularRegion +import io.getstream.android.core.api.model.location.toRequestMap import io.getstream.android.core.internal.filter.BinaryOperator import io.getstream.android.core.internal.filter.CollectionOperator import io.getstream.android.core.internal.filter.FilterOperations @@ -45,11 +48,20 @@ internal data class CollectionOperationFilter>( @StreamInternalApi public fun Filter<*, *>.toRequest(): Map = when (this) { - is BinaryOperationFilter<*, *> -> mapOf(field.remote to mapOf(operator.remote to value)) + is BinaryOperationFilter<*, *> -> + mapOf(field.remote to mapOf(operator.remote to value.toRequestValue())) + is CollectionOperationFilter<*, *> -> mapOf(operator.remote to filters.map(Filter<*, *>::toRequest)) } +private fun Any.toRequestValue() = + when (this) { + is CircularRegion -> toRequestMap() + is BoundingBox -> toRequestMap() + else -> this + } + /** Checks if this filter matches the given item. */ @StreamInternalApi public infix fun > Filter.matches(item: M): Boolean = @@ -61,7 +73,7 @@ public infix fun > Filter.matches(item: M): Boolean with(FilterOperations) { when (operator) { - BinaryOperator.EQUAL -> notNull && fieldValue == filterValue + BinaryOperator.EQUAL -> notNull && fieldValue equal filterValue BinaryOperator.GREATER -> notNull && fieldValue greater filterValue BinaryOperator.LESS -> notNull && fieldValue less filterValue BinaryOperator.GREATER_OR_EQUAL -> @@ -71,7 +83,7 @@ public infix fun > Filter.matches(item: M): Boolean BinaryOperator.QUERY -> notNull && search(filterValue, where = fieldValue) BinaryOperator.AUTOCOMPLETE -> notNull && fieldValue autocompletes filterValue BinaryOperator.EXISTS -> fieldValue exists filterValue - BinaryOperator.CONTAINS -> notNull && fieldValue contains filterValue + BinaryOperator.CONTAINS -> notNull && fieldValue doesContain filterValue BinaryOperator.PATH_EXISTS -> notNull && fieldValue containsPath filterValue } } diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/BoundingBox.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/BoundingBox.kt new file mode 100644 index 0000000..679ecd9 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/BoundingBox.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.android.core.api.model.location + +import io.getstream.android.core.annotations.StreamPublishedApi + +/** + * A rectangular geographic region defined by northeast and southwest corner coordinates. + * + * @param northeast The northeast (top-right) corner coordinate of the bounding box. + * @param southwest The southwest (bottom-left) corner coordinate of the bounding box. + */ +@StreamPublishedApi +public data class BoundingBox(val northeast: LocationCoordinate, val southwest: LocationCoordinate) + +/** + * Checks if the specified coordinate is within this bounding box. + * + * @param coordinate The coordinate to check. + * @return True if the coordinate is within the bounding box, false otherwise. + */ +internal operator fun BoundingBox.contains(coordinate: LocationCoordinate): Boolean { + return coordinate.latitude >= southwest.latitude && + coordinate.latitude <= northeast.latitude && + coordinate.longitude >= southwest.longitude && + coordinate.longitude <= northeast.longitude +} + +/** Converts this bounding box to a map representation suitable for API requests. */ +internal fun BoundingBox.toRequestMap(): Map = + mapOf( + "ne_lat" to northeast.latitude, + "ne_lng" to northeast.longitude, + "sw_lat" to southwest.latitude, + "sw_lng" to southwest.longitude, + ) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/CircularRegion.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/CircularRegion.kt new file mode 100644 index 0000000..36e9198 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/CircularRegion.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.android.core.api.model.location + +import android.location.Location +import io.getstream.android.core.annotations.StreamPublishedApi + +/** + * A circular geographic region defined by a center point and a radius. + * + * @param center The center coordinate of the circular region. + * @param radius The radius of the circular region. + */ +@StreamPublishedApi +public data class CircularRegion(val center: LocationCoordinate, val radius: Distance) + +/** + * Checks if the specified coordinate is within this circular region. + * + * @param coordinate The coordinate to check. + * @return True if the coordinate is within the region, false otherwise. + */ +internal operator fun CircularRegion.contains(coordinate: LocationCoordinate): Boolean { + val centerLocation = + Location("").apply { + latitude = this@contains.center.latitude + longitude = this@contains.center.longitude + } + val coordinateLocation = + Location("").apply { + latitude = coordinate.latitude + longitude = coordinate.longitude + } + val distance = centerLocation.distanceTo(coordinateLocation).toDouble() + return distance <= this@contains.radius.inMeters +} + +/** Converts this circular region to a map representation suitable for API requests. */ +internal fun CircularRegion.toRequestMap(): Map = + mapOf("lat" to center.latitude, "lng" to center.longitude, "distance" to radius.inKilometers) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/Distance.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/Distance.kt new file mode 100644 index 0000000..99a9aba --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/Distance.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.android.core.api.model.location + +import io.getstream.android.core.annotations.StreamPublishedApi + +/** + * Represents a distance measurement with type safety and automatic unit conversion. + * + * @param inMeters The distance value stored internally in meters. + */ +@JvmInline +@StreamPublishedApi +public value class Distance private constructor(public val inMeters: Double) { + + /** Returns the distance value in kilometers. */ + public val inKilometers: Double + get() = inMeters / 1000.0 + + internal companion object { + fun fromMeters(meters: Double): Distance = Distance(meters) + } +} + +/** + * Extension property to create a [Distance] from a [Double] value in meters. + * + * ## Example + * + * ```kotlin + * val distance = 1500.2.meters // 1500.2 meters + * ``` + */ +@StreamPublishedApi +public val Double.meters: Distance + get() = Distance.fromMeters(this) + +/** + * Extension property to create a [Distance] from a [Double] value in kilometers. + * + * ## Example + * + * ```kotlin + * val distance = 1.5.kilometers // 1.5 kilometers + * ``` + */ +@StreamPublishedApi +public val Double.kilometers: Distance + get() = Distance.fromMeters(this * 1000.0) + +/** + * Extension property to create a [Distance] from an [Int] value in meters. + * + * ## Example + * + * ```kotlin + * val distance = 1500.meters // 1500 meters + * ``` + */ +@StreamPublishedApi +public val Int.meters: Distance + get() = toDouble().meters + +/** + * Extension property to create a [Distance] from an [Int] value in kilometers. + * + * ## Example + * + * ```kotlin + * val distance = 5.kilometers // 5 kilometers + * ``` + */ +@StreamPublishedApi +public val Int.kilometers: Distance + get() = toDouble().kilometers diff --git a/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/LocationCoordinate.kt b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/LocationCoordinate.kt new file mode 100644 index 0000000..0097783 --- /dev/null +++ b/stream-android-core/src/main/java/io/getstream/android/core/api/model/location/LocationCoordinate.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.android.core.api.model.location + +import io.getstream.android.core.annotations.StreamPublishedApi + +/** + * Represents a geographic coordinate with latitude and longitude values. + * + * This class is used to represent location data in geo-spatial filtering operations. + * + * @param latitude The latitude coordinate in degrees. Must be between -90 and 90. + * @param longitude The longitude coordinate in degrees. Must be between -180 and 180. + */ +@StreamPublishedApi +public data class LocationCoordinate(val latitude: Double, val longitude: Double) diff --git a/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt index 57ce195..75a0dc9 100644 --- a/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt +++ b/stream-android-core/src/main/java/io/getstream/android/core/internal/filter/FilterOperations.kt @@ -16,7 +16,15 @@ package io.getstream.android.core.internal.filter +import io.getstream.android.core.api.model.location.BoundingBox +import io.getstream.android.core.api.model.location.CircularRegion +import io.getstream.android.core.api.model.location.LocationCoordinate +import io.getstream.android.core.api.model.location.contains +import io.getstream.android.core.api.model.location.kilometers + internal object FilterOperations { + infix fun Any.equal(that: Any) = this == that || isNear(that) || isWithinBoundsOf(that) + infix fun Any.greater(that: Any) = anyCompare(this, that)?.let { it > 0 } == true infix fun Any.greaterOrEqual(that: Any) = anyCompare(this, that)?.let { it >= 0 } == true @@ -47,7 +55,8 @@ internal object FilterOperations { else -> false } - infix fun Any.contains(that: Any): Boolean = + // Not called "contains" to avoid overloading `Any.contains`, which is too broad + infix fun Any.doesContain(that: Any): Boolean = when { that `in` this -> true @@ -57,7 +66,7 @@ internal object FilterOperations { val thisValue = this[thatKey] thisValue == thatValue || - thisValue != null && thatValue != null && thisValue contains thatValue + thisValue != null && thatValue != null && thisValue doesContain thatValue } } @@ -97,4 +106,51 @@ internal object FilterOperations { return true } + + private fun Any.isNear(that: Any): Boolean { + if (this !is LocationCoordinate) return false + + return when (that) { + is CircularRegion -> this in that + is Map<*, *> -> { + // Handle map format as expected by the API: + // { "lat": 41.8904, "lng": 12.4922, "distance": 5.0 } + val lat = (that["lat"] as? Number)?.toDouble() ?: return false + val lng = (that["lng"] as? Number)?.toDouble() ?: return false + val distanceKm = (that["distance"] as? Number)?.toDouble() ?: return false + + this in + CircularRegion( + center = LocationCoordinate(lat, lng), + radius = distanceKm.kilometers, + ) + } + + else -> false + } + } + + private fun Any.isWithinBoundsOf(that: Any): Boolean { + if (this !is LocationCoordinate) return false + + return when (that) { + is BoundingBox -> this in that + is Map<*, *> -> { + // Handle map format as expected by the API: + // { "ne_lat": 41.9200, "ne_lng": 12.5200, "sw_lat": 41.8800, "sw_lng": 12.4700 } + val neLat = (that["ne_lat"] as? Number)?.toDouble() ?: return false + val neLng = (that["ne_lng"] as? Number)?.toDouble() ?: return false + val swLat = (that["sw_lat"] as? Number)?.toDouble() ?: return false + val swLng = (that["sw_lng"] as? Number)?.toDouble() ?: return false + + this in + BoundingBox( + northeast = LocationCoordinate(neLat, neLng), + southwest = LocationCoordinate(swLat, swLng), + ) + } + + else -> false + } + } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt index 3a3e832..d0dcb43 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterMatchingTest.kt @@ -16,6 +16,8 @@ package io.getstream.android.core.api.filter +import io.getstream.android.core.api.model.location.BoundingBox +import io.getstream.android.core.api.model.location.LocationCoordinate import kotlin.test.assertFalse import kotlin.test.assertTrue import org.junit.Test @@ -296,6 +298,37 @@ class FilterMatchingTest { assertTrue(filter matches activeItemWithLowRating) } + @Test + fun `equal filter should match when LocationCoordinate is within BoundingBox`() { + val northeast = LocationCoordinate(latitude = 41.91, longitude = 12.51) + val southwest = LocationCoordinate(latitude = 41.87, longitude = 12.47) + val boundingBox = BoundingBox(northeast = northeast, southwest = southwest) + val filter = TestFilterField.withinBounds.equal(boundingBox) + + val itemWithLocationInside = + TestData(location = LocationCoordinate(latitude = 41.89, longitude = 12.49)) + val itemWithLocationOutside = + TestData(location = LocationCoordinate(latitude = 41.95, longitude = 12.49)) + + assertTrue(filter matches itemWithLocationInside) + assertFalse(filter matches itemWithLocationOutside) + } + + @Test + fun `equal filter should work with bounding box map format from API`() { + val mapBounds = + mapOf("ne_lat" to 41.91, "ne_lng" to 12.51, "sw_lat" to 41.87, "sw_lng" to 12.47) + val filter = TestFilterField.withinBounds.equal(mapBounds) + + val itemWithLocationInside = + TestData(location = LocationCoordinate(latitude = 41.89, longitude = 12.49)) + val itemWithLocationOutside = + TestData(location = LocationCoordinate(latitude = 41.95, longitude = 12.49)) + + assertTrue(filter matches itemWithLocationInside) + assertFalse(filter matches itemWithLocationOutside) + } + private data class TestData( val id: String = "default-id", val name: String = "Default Name", @@ -304,6 +337,7 @@ class FilterMatchingTest { val tags: List = emptyList(), val metadata: Map? = null, val isActive: Boolean = false, + val location: LocationCoordinate? = null, ) private data class TestFilterField( @@ -318,6 +352,7 @@ class FilterMatchingTest { val tags = TestFilterField("tags", TestData::tags) val metadata = TestFilterField("metadata", TestData::metadata) val isActive = TestFilterField("isActive", TestData::isActive) + val withinBounds = TestFilterField("within_bounds", TestData::location) } } } diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterRobolectricTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterRobolectricTest.kt new file mode 100644 index 0000000..b8a49be --- /dev/null +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterRobolectricTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-core-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.android.core.api.filter + +import android.os.Build +import io.getstream.android.core.api.model.location.CircularRegion +import io.getstream.android.core.api.model.location.LocationCoordinate +import io.getstream.android.core.api.model.location.kilometers +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class FilterRobolectricTest { + + private val centerCoordinate = LocationCoordinate(latitude = 41.8900, longitude = 12.4900) + private val nearbyCoordinate = LocationCoordinate(latitude = 41.8920, longitude = 12.4920) + private val farCoordinate = LocationCoordinate(latitude = 42.8900, longitude = 12.4900) + + @Test + fun `equal filter should match when LocationCoordinate is within CircularRegion`() { + val region = CircularRegion(center = centerCoordinate, radius = 5.kilometers) + val filter = testFilterField.equal(region) + + assertTrue(filter matches TestData(location = nearbyCoordinate)) + assertFalse(filter matches TestData(location = farCoordinate)) + } + + @Test + fun `equal filter should work with circular region map format from API`() { + val mapRegion = mapOf("lat" to 41.8920, "lng" to 12.4920, "distance" to 5.0) + val filter = testFilterField.equal(mapRegion) + + assertTrue(filter matches TestData(location = centerCoordinate)) + assertFalse(filter matches TestData(location = farCoordinate)) + } + + private data class TestData(val location: LocationCoordinate) + + private val testFilterField = + object : FilterField { + override val remote: String = "location" + override val localValue: (TestData) -> Any? = TestData::location + } +} diff --git a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt index e603403..075a257 100644 --- a/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt +++ b/stream-android-core/src/test/java/io/getstream/android/core/api/filter/FilterToRequestTest.kt @@ -16,6 +16,10 @@ package io.getstream.android.core.api.filter +import io.getstream.android.core.api.model.location.BoundingBox +import io.getstream.android.core.api.model.location.CircularRegion +import io.getstream.android.core.api.model.location.LocationCoordinate +import io.getstream.android.core.api.model.location.kilometers import junit.framework.TestCase import kotlin.test.fail import org.junit.Test @@ -45,6 +49,8 @@ internal class FilterToRequestTest( private val textField = TestField("text") private val filterTagsField = TestField("filter_tags") private val searchDataField = TestField("search_data") + private val nearField = TestField("near") + private val withinBoundsField = TestField("within_bounds") @JvmStatic @Parameterized.Parameters(name = "{2}") @@ -55,6 +61,43 @@ internal class FilterToRequestTest( mapOf("id" to mapOf("\$eq" to "activity-123")), "Field equals value", ), + arrayOf( + nearField.equal( + CircularRegion( + center = LocationCoordinate(latitude = 41.8900, longitude = 12.4900), + radius = 5.kilometers, + ) + ), + mapOf( + "near" to + mapOf( + "\$eq" to + mapOf("lat" to 41.8900, "lng" to 12.4900, "distance" to 5.0) + ) + ), + "Location equal to circular region", + ), + arrayOf( + withinBoundsField.equal( + BoundingBox( + northeast = LocationCoordinate(latitude = 41.91, longitude = 12.51), + southwest = LocationCoordinate(latitude = 41.87, longitude = 12.47), + ) + ), + mapOf( + "within_bounds" to + mapOf( + "\$eq" to + mapOf( + "ne_lat" to 41.91, + "ne_lng" to 12.51, + "sw_lat" to 41.87, + "sw_lng" to 12.47, + ) + ) + ), + "Location equal to bounding box", + ), arrayOf( createdAtField.greater(1234567890), mapOf("created_at" to mapOf("\$gt" to 1234567890)),