
Lightweight geospatial toolkit offering Base32 geohash encoding/decoding, Haversine distance math, bounding-box queries, real-time Firestore geo-queries with reactive streams, deduplication and client-side distance filtering.
GeoFlare is a modern, lightweight Kotlin Multiplatform (KMP) library for geospatial queries and geohashing, inspired by Firebase's GeoFire and geofire-common.
Unlike traditional GeoFire libraries tightly coupled to a single SDK or legacy callback patterns, GeoFlare is modular, cross-platform, and designed for modern Kotlin Coroutines and Flow.
geoflare-core: Pure Kotlin Multiplatform module with zero external dependencies. Handles Base32 Geohash encoding/decoding (Z-order space-filling curve), Haversine distance calculations, WGS84 geodesy, and bounding box query bounds math.geoflare-firestore: Seamless integration with Cloud Firestore using Kotlin Coroutines and Flow, powered by the GitLive Firebase SDK (dev.gitlive:firebase-firestore). Provides real-time geo-queries, parallel fetching, deduplication, and automatic client-side distance filtering.| Module | JVM | Android | iOS | Linux |
|---|---|---|---|---|
geoflare-core |
✅ | ✅ | ✅ (arm64, simulatorArm64, x64) |
✅ (x64) |
geoflare-firestore |
✅ | ✅ | ✅ (arm64, simulatorArm64, x64) |
— |
Add the dependencies to your commonMain source set in build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
// Pure geohashing and math (zero dependencies)
implementation("com.dauvalter.geoflare:geoflare-core:0.2.0")
// Firestore Coroutines & Flow integration
implementation("com.dauvalter.geoflare:geoflare-firestore:0.2.0")
}
}
}import com.dauvalter.geoflare.core.GeoLocation
import com.dauvalter.geoflare.core.GeoMath
val sf = GeoLocation(latitude = 37.7749, longitude = -122.4194)
val sj = GeoLocation(latitude = 37.3382, longitude = -121.8863)
// Calculate Haversine distance in kilometers or meters
val distanceKm = GeoMath.distance(sf, sj) // ~67.8 km
val distanceMeters = GeoMath.distanceInMeters(sf, sj)import com.dauvalter.geoflare.core.GeohashUtils
// Encode coordinates to a geohash (default precision: 10 chars)
val hash = GeohashUtils.encode(sf) // "9q8yyk8ytp"
// Encode with custom precision (1 to 22 chars)
val shortHash = GeohashUtils.encode(sf, precision = 5) // "9q8yy"
// Decode back to approximate coordinates
val location = GeohashUtils.decode(hash)Store a geohash field in your Firestore documents using GeohashUtils.encode(location). Then query reactively:
import dev.gitlive.firebase.Firebase
import dev.gitlive.firebase.firestore.firestore
import com.dauvalter.geoflare.core.GeoLocation
import com.dauvalter.geoflare.firestore.geoSnapshots
import kotlinx.serialization.Serializable
@Serializable
data class Place(
val name: String,
val geohash: String,
val latitude: Double,
val longitude: Double
) {
val location: GeoLocation get() = GeoLocation(latitude, longitude)
}
val center = GeoLocation(latitude = 37.7749, longitude = -122.4194)
// Real-time Flow of nearby places with automatic distance calculation & filtering:
val nearbyPlacesFlow = Firebase.firestore.collection("places")
.geoSnapshots<Place>(
center = center,
radiusInKm = 5.0,
geohashField = "geohash", // default
sortByDistance = true, // default
locationExtractor = { it.location }
)
nearbyPlacesFlow.collect { results ->
for (result in results) {
println("${result.data.name} is ${result.distanceInKm} km away")
}
}import com.dauvalter.geoflare.firestore.geoGet
// Suspending one-shot fetch (queries all ranges concurrently):
val places: List<GeoQueryResult<Place>> = Firebase.firestore.collection("places")
.geoGet<Place>(
center = center,
radiusInKm = 5.0,
locationExtractor = { it.location }
)For additional filters, start from a collection with geoQuery():
import com.dauvalter.geoflare.firestore.geoQuery
import com.dauvalter.geoflare.firestore.geoCollectionGroup
val cafes = Firebase.firestore.collection("places")
.geoQuery()
.where { "category" equalTo "cafe" }
.geoGet<Place>(center = center, radiusInKm = 5.0) { it.location }
val allPlaces = Firebase.firestore.geoCollectionGroup("places")
.geoSnapshots<Place>(center = center, radiusInKm = 5.0) { it.location }GeoFlare owns query ordering and cursors. Passing an already configured SDK
Query (including where, orderBy, limit, or cursors) throws
IllegalArgumentException before querying Firestore. Migrate
collection.where { ... }.geoGet(...) to
collection.geoQuery().where { ... }.geoGet(...).
For the nearest N documents, apply take(N) to the distance-sorted result list;
a server-side limit on geohash candidates cannot guarantee nearest neighbors.
Firestore index requirements still apply to your filters.
To drive map animations, sound/push alerts, or geo-fences, transform the snapshot flow into individual lifecycle events:
import com.dauvalter.geoflare.firestore.GeoEvent
import com.dauvalter.geoflare.firestore.asGeoEvents
nearbyPlacesFlow.asGeoEvents().collect { event ->
when (event) {
is GeoEvent.Entered -> {
println("Entered radius: ${event.item.data.name} (${event.item.distanceInKm} km away)")
}
is GeoEvent.Moved -> {
println("Moved within radius: ${event.item.data.name} (now ${event.item.distanceInKm} km away)")
}
is GeoEvent.Exited -> {
println("Exited radius: document ID ${event.id}")
}
}
}Use result.key (the full document path) as the identity for map markers and
collection-group results. result.id remains the short Firestore document ID
for compatibility. Exit events expose event.key as well. Results constructed
without a snapshot use id as their key, including after copy(id = ...).
Easily save or update coordinates and let GeoFlare compute and store the geohash:
import com.dauvalter.geoflare.firestore.setGeoLocation
import com.dauvalter.geoflare.firestore.updateGeoLocation
val location = GeoLocation(latitude = 37.7749, longitude = -122.4194)
// Saves geohash, latitude, and longitude (merged into document)
Firebase.firestore.collection("places").document("cafe-1")
.setGeoLocation(location)
// Update existing document coordinates
Firebase.firestore.collection("places").document("cafe-1")
.updateGeoLocation(location)Write helpers store 10-character geohashes by default. Queries use the same default
and cap their bounds to that precision, including for submeter radii. If you store
shorter hashes with precision, pass the same value as geohashPrecision to
geoGet, geoSnapshots, their raw/meter variants, or GeoQueryCriteria.
For mixed lengths, use the shortest stored length. Distance filtering still uses
the original coordinates; shorter hashes increase the number of candidate reads.
When users pan or zoom an interactive map, pass a Flow<GeoQueryCriteria> to automatically switch range subscriptions on the fly:
import com.dauvalter.geoflare.firestore.GeoQueryCriteria
import kotlinx.coroutines.flow.MutableStateFlow
val cameraCriteria = MutableStateFlow(GeoQueryCriteria(center = sf, radiusInKm = 5.0))
// Automatically switches Firestore subscriptions whenever criteria changes:
val mapPlacesFlow = Firebase.firestore.collection("places")
.geoSnapshots<Place>(
criteriaFlow = cameraCriteria,
locationExtractor = { it.location }
)
// Later, when user pans the map or changes zoom:
cameraCriteria.value = GeoQueryCriteria(center = newCenter, radiusInKm = 10.0)To build the library and run tests across all modules and targets:
./gradlew checkCopyright 2026 Anton Dauwalter
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
GeoFlare is a modern, lightweight Kotlin Multiplatform (KMP) library for geospatial queries and geohashing, inspired by Firebase's GeoFire and geofire-common.
Unlike traditional GeoFire libraries tightly coupled to a single SDK or legacy callback patterns, GeoFlare is modular, cross-platform, and designed for modern Kotlin Coroutines and Flow.
geoflare-core: Pure Kotlin Multiplatform module with zero external dependencies. Handles Base32 Geohash encoding/decoding (Z-order space-filling curve), Haversine distance calculations, WGS84 geodesy, and bounding box query bounds math.geoflare-firestore: Seamless integration with Cloud Firestore using Kotlin Coroutines and Flow, powered by the GitLive Firebase SDK (dev.gitlive:firebase-firestore). Provides real-time geo-queries, parallel fetching, deduplication, and automatic client-side distance filtering.| Module | JVM | Android | iOS | Linux |
|---|---|---|---|---|
geoflare-core |
✅ | ✅ | ✅ (arm64, simulatorArm64, x64) |
✅ (x64) |
geoflare-firestore |
✅ | ✅ | ✅ (arm64, simulatorArm64, x64) |
— |
Add the dependencies to your commonMain source set in build.gradle.kts:
kotlin {
sourceSets {
commonMain.dependencies {
// Pure geohashing and math (zero dependencies)
implementation("com.dauvalter.geoflare:geoflare-core:0.2.0")
// Firestore Coroutines & Flow integration
implementation("com.dauvalter.geoflare:geoflare-firestore:0.2.0")
}
}
}import com.dauvalter.geoflare.core.GeoLocation
import com.dauvalter.geoflare.core.GeoMath
val sf = GeoLocation(latitude = 37.7749, longitude = -122.4194)
val sj = GeoLocation(latitude = 37.3382, longitude = -121.8863)
// Calculate Haversine distance in kilometers or meters
val distanceKm = GeoMath.distance(sf, sj) // ~67.8 km
val distanceMeters = GeoMath.distanceInMeters(sf, sj)import com.dauvalter.geoflare.core.GeohashUtils
// Encode coordinates to a geohash (default precision: 10 chars)
val hash = GeohashUtils.encode(sf) // "9q8yyk8ytp"
// Encode with custom precision (1 to 22 chars)
val shortHash = GeohashUtils.encode(sf, precision = 5) // "9q8yy"
// Decode back to approximate coordinates
val location = GeohashUtils.decode(hash)Store a geohash field in your Firestore documents using GeohashUtils.encode(location). Then query reactively:
import dev.gitlive.firebase.Firebase
import dev.gitlive.firebase.firestore.firestore
import com.dauvalter.geoflare.core.GeoLocation
import com.dauvalter.geoflare.firestore.geoSnapshots
import kotlinx.serialization.Serializable
@Serializable
data class Place(
val name: String,
val geohash: String,
val latitude: Double,
val longitude: Double
) {
val location: GeoLocation get() = GeoLocation(latitude, longitude)
}
val center = GeoLocation(latitude = 37.7749, longitude = -122.4194)
// Real-time Flow of nearby places with automatic distance calculation & filtering:
val nearbyPlacesFlow = Firebase.firestore.collection("places")
.geoSnapshots<Place>(
center = center,
radiusInKm = 5.0,
geohashField = "geohash", // default
sortByDistance = true, // default
locationExtractor = { it.location }
)
nearbyPlacesFlow.collect { results ->
for (result in results) {
println("${result.data.name} is ${result.distanceInKm} km away")
}
}import com.dauvalter.geoflare.firestore.geoGet
// Suspending one-shot fetch (queries all ranges concurrently):
val places: List<GeoQueryResult<Place>> = Firebase.firestore.collection("places")
.geoGet<Place>(
center = center,
radiusInKm = 5.0,
locationExtractor = { it.location }
)For additional filters, start from a collection with geoQuery():
import com.dauvalter.geoflare.firestore.geoQuery
import com.dauvalter.geoflare.firestore.geoCollectionGroup
val cafes = Firebase.firestore.collection("places")
.geoQuery()
.where { "category" equalTo "cafe" }
.geoGet<Place>(center = center, radiusInKm = 5.0) { it.location }
val allPlaces = Firebase.firestore.geoCollectionGroup("places")
.geoSnapshots<Place>(center = center, radiusInKm = 5.0) { it.location }GeoFlare owns query ordering and cursors. Passing an already configured SDK
Query (including where, orderBy, limit, or cursors) throws
IllegalArgumentException before querying Firestore. Migrate
collection.where { ... }.geoGet(...) to
collection.geoQuery().where { ... }.geoGet(...).
For the nearest N documents, apply take(N) to the distance-sorted result list;
a server-side limit on geohash candidates cannot guarantee nearest neighbors.
Firestore index requirements still apply to your filters.
To drive map animations, sound/push alerts, or geo-fences, transform the snapshot flow into individual lifecycle events:
import com.dauvalter.geoflare.firestore.GeoEvent
import com.dauvalter.geoflare.firestore.asGeoEvents
nearbyPlacesFlow.asGeoEvents().collect { event ->
when (event) {
is GeoEvent.Entered -> {
println("Entered radius: ${event.item.data.name} (${event.item.distanceInKm} km away)")
}
is GeoEvent.Moved -> {
println("Moved within radius: ${event.item.data.name} (now ${event.item.distanceInKm} km away)")
}
is GeoEvent.Exited -> {
println("Exited radius: document ID ${event.id}")
}
}
}Use result.key (the full document path) as the identity for map markers and
collection-group results. result.id remains the short Firestore document ID
for compatibility. Exit events expose event.key as well. Results constructed
without a snapshot use id as their key, including after copy(id = ...).
Easily save or update coordinates and let GeoFlare compute and store the geohash:
import com.dauvalter.geoflare.firestore.setGeoLocation
import com.dauvalter.geoflare.firestore.updateGeoLocation
val location = GeoLocation(latitude = 37.7749, longitude = -122.4194)
// Saves geohash, latitude, and longitude (merged into document)
Firebase.firestore.collection("places").document("cafe-1")
.setGeoLocation(location)
// Update existing document coordinates
Firebase.firestore.collection("places").document("cafe-1")
.updateGeoLocation(location)Write helpers store 10-character geohashes by default. Queries use the same default
and cap their bounds to that precision, including for submeter radii. If you store
shorter hashes with precision, pass the same value as geohashPrecision to
geoGet, geoSnapshots, their raw/meter variants, or GeoQueryCriteria.
For mixed lengths, use the shortest stored length. Distance filtering still uses
the original coordinates; shorter hashes increase the number of candidate reads.
When users pan or zoom an interactive map, pass a Flow<GeoQueryCriteria> to automatically switch range subscriptions on the fly:
import com.dauvalter.geoflare.firestore.GeoQueryCriteria
import kotlinx.coroutines.flow.MutableStateFlow
val cameraCriteria = MutableStateFlow(GeoQueryCriteria(center = sf, radiusInKm = 5.0))
// Automatically switches Firestore subscriptions whenever criteria changes:
val mapPlacesFlow = Firebase.firestore.collection("places")
.geoSnapshots<Place>(
criteriaFlow = cameraCriteria,
locationExtractor = { it.location }
)
// Later, when user pans the map or changes zoom:
cameraCriteria.value = GeoQueryCriteria(center = newCenter, radiusInKm = 10.0)To build the library and run tests across all modules and targets:
./gradlew checkCopyright 2026 Anton Dauwalter
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0