Skip to content

feat(location): Kotlin Multiplatform geolocation module - #726

Draft
kdroidFilter wants to merge 3 commits into
nucleus-2.6from
feat/location
Draft

kdroidFilter wants to merge 3 commits into
nucleus-2.6from
feat/location

Conversation

@kdroidFilter

Copy link
Copy Markdown
Collaborator

Adds nucleus.location, a Kotlin Multiplatform geolocation module modelled on robius-location. Rust is used on desktop only; every other target talks to the platform from Kotlin.

Target Backend
JVM — Windows WinRT Windows.Devices.Geolocation.Geolocator (Rust JNI bridge, windows-rs)
JVM — macOS Core Location (Rust JNI bridge, objc2) on a dedicated CFRunLoop thread
JVM — Linux XDG Location portal, falling back to GeoClue 2 (Rust JNI bridge, zbus, no libdbus)
Android Framework LocationManager — fused provider on API 31+, GPS / network below. No Play services
iOS (iosArm64, iosSimulatorArm64) Core Location (K/N)
Web (js, wasmJs) W3C Geolocation API (navigator.geolocation + Permissions API)

Documentation

Installation

dependencies {
    implementation("dev.nucleusframework:nucleus.location:<version>")
}

Usage

// One fix; a fix already acquired in the last 5 minutes comes back without waiting.
val here = Geolocation.currentLocation(LocationAccuracy.Approximate, maxAge = 5.minutes)

// Fixes as the device moves, until the collecting coroutine is cancelled.
Geolocation.locationUpdates(minInterval = 2.seconds, minDistanceMeters = 10.0)
    .collect { location -> show(location.latitude, location.longitude) }

Both ask for authorization when it has not been determined yet, and throw LocationException(LocationError.AuthorizationDenied) when it is refused. To choose when the prompt appears — or to ask for background access — call it first:

when (Geolocation.requestAuthorization(LocationAccess.Foreground, LocationAccuracy.Precise)) {
    LocationAuthorization.Foreground, LocationAuthorization.Background -> startTracking()
    else -> explainWhyLocationHelps()
}

Geolocation implements LocationProvider; depend on the interface to substitute a fake in tests.

API

Member
isAvailable whether the platform has a reachable location service
authorization() current LocationAuthorization (NotDetermined, Denied, Restricted, Foreground, Background), no prompt
requestAuthorization(access, accuracy) prompts if nothing is recorded yet; suspends while the prompt is shown
currentLocation(accuracy, maxAge = ZERO, timeout = 30.seconds) one fix
locationUpdates(accuracy, minInterval = 1.seconds, minDistanceMeters = 0.0) cold Flow<Location>

Location: latitude, longitude, altitude?, horizontalAccuracy?, verticalAccuracy?, bearing?, speed?, timestampMillis, isCached. Optional values are null when the platform did not measure them.

LocationError: AuthorizationDenied, TemporarilyUnavailable, PermanentlyUnavailable, Network, Timeout, Unknown.

Semantics

  • currentLocation(maxAge = Duration.ZERO) (the default) always measures a new fix. A positive maxAge lets the platform answer from its cache (Location.isCached), which is instant.
  • timeout bounds the whole request and fails with LocationError.Timeout.
  • locationUpdates is cold: each collector opens its own platform session, stopped when the collection ends. Transient outages (TemporarilyUnavailable, Network) are ridden out; the flow fails on AuthorizationDenied / PermanentlyUnavailable.
  • minInterval and minDistanceMeters are hints the platform may not honour exactly.
  • Every member is safe to call from any thread.

Platform setup

Android

Declare the permissions you use — the library declares none, since which ones an app holds is its own decision:

<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- LocationAccuracy.Precise -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- LocationAccess.Background -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

The prompt needs a resumed activity; the library tracks it through a ContentProvider merged into the app manifest (no androidx.startup dependency). Removing that provider disables the library. On Android 12+ a precise request shows the system's precise / approximate choice in one dialog; background access is requested separately after a foreground grant, as the platform requires.

iOS and macOS

Core Location only prompts an app whose Info.plist explains why: NSLocationWhenInUseUsageDescription (plus NSLocationAlwaysAndWhenInUseUsageDescription for background; macOS also reads NSLocationUsageDescription). With the Nucleus plugin:

nucleus.application {
    nativeDistributions {
        macOS {
            infoPlist {
                extraKeysRawXml = """
                    <key>NSLocationUsageDescription</key>
                    <string>Shows the weather where you are.</string>
                    <key>NSLocationWhenInUseUsageDescription</key>
                    <string>Shows the weather where you are.</string>
                """.trimIndent()
            }
        }
    }
}

An unpackaged run (./gradlew run, the IDE) has no such Info.plist and is never authorized on macOS — use runDistributable or a packaged build. A sandboxed (App Store) build also needs the com.apple.security.personal-information.location entitlement.

Windows

No manifest capability. Access follows Settings → Privacy & security → Location → Let desktop apps access your location; Windows 11 24H2+ prompts on the first request. authorization() reports NotDetermined until a request in the current process has been answered — WinRT cannot tell "allowed" from "never asked" without asking.

Linux

The XDG Location portal is used whenever present, sandboxed or not: it shows the desktop's consent dialog and needs nothing from the app. Without a portal (or without its Location interface), an unsandboxed app talks to GeoClue 2 directly, attributing the request to its .desktop id (the installed desktop entry, else NucleusApp.appId). A portal refusal is final — GeoClue is never tried behind the user's back. authorization() reports NotDetermined until a request in this process succeeded.

Web

Secure context only (HTTPS or localhost). Browsers prompt only when a position is requested, so requestAuthorization asks for one. authorization() reads the Permissions API state, queried on first use and kept current by its change event — the very first call may still report NotDetermined. watchPosition has no interval or distance options: minInterval / minDistanceMeters are ignored and the browser reports when the position changes. LocationAccess.Background means nothing to a page.

Native library

libnucleus_location is built from location/src/main/native (Rust crate; cargo + rustup required) by buildNativeWindows / buildNativeMacOs / buildNativeLinux on the matching host and ships in the JVM artifact under nucleus/native/<platform>-<arch>/. GraalVM reachability metadata is included.


Architecture

  • commonMain holds the API and all the semantics: BackendLocationProvider implements the implicit authorization request, cached-fix acceptance by maxAge, timeouts and the flow once, on top of an internal LocationBackend.
  • Each platform implements only LocationBackend: authorization plus sessions — a stream of fixes and errors between start and stop.
  • Rust bridge (jvmMain + src/main/native): sessions are identified by Kotlin-allocated ids; the native side reserves a slot before starting, so a stop racing the start (a one-shot can finish before nativeStart returns) drops the session instead of leaking it. Pending JNI exceptions go to JniExceptionReporter, never a silent clear.
  • macOS: every CLLocationManager lives on one nucleus-location thread running a CFRunLoop (Core Location delivers on the creating thread's run loop; the JVM main thread is not ours to rely on). Requests made while authorization is undetermined are deferred until the answer, as in robius-location.
  • Android: the prompt's answer is read back from checkSelfPermission on the activity's pause → resume (the dialog is an activity of its own); a request answered without a dialog ("don't ask again") is detected by the absence of a pause.
  • Web: the browser is reached only through js()-bodied helpers passing primitives and lambdas, so a single webMain source compiles for Kotlin/JS and Kotlin/Wasm.

Build changes outside the module

  • nucleus.native-module supports KMP modules: libraries stay under src/main/resources/nucleus/native (the path every CI workflow caches / uploads / verifies) and are copied into the jvm target only via jvmProcessResources (matched by name — buildSrc needs no KGP on its classpath), together with the native-libraries manifest.
  • detekt on KMP modules: the plain detekt task that check runs only looked at src/main/kotlin (NO-SOURCE on a KMP module); it now covers every *Main/kotlin source set so the KDoc rules apply.
  • Root clean now comes from the base plugin: Kotlin/JS applies the lifecycle plugin to the root project, which rejects a hand-registered clean. Behaviour unchanged (deletes the root build/).
  • kotlin-js-store/ — yarn locks for JS and Wasm (kotlinUpgradeYarnLock / kotlinWasmUpgradeYarnLock).
  • Catalog: com.android.kotlin.multiplatform.library plugin, kotlinx-coroutines-test.
  • gradle.properties: kotlin.native.ignoreDisabledTargets=true — iOS klibs cross-compile on any host (Kotlin 2.4); only their tests need macOS.
  • CI: build-natives.yaml build steps + verify lists; pre-merge.yaml / publish-maven.yaml expected natives.
  • Demos: examples/location-demo (console, desktop), examples/location-web-demo (jsBrowserDevelopmentRun).
  • BCV dumps the jvm API only (location/api/jvm/location.api); the other targets expose the same common API.

Test plan

  • cargo check clean (0 warnings) for x86_64-pc-windows-msvc, aarch64-apple-darwin, x86_64-unknown-linux-gnu; Windows release DLL builds
  • :location:check — ktlint, detekt, apiCheck; common tests 9/9 on JVM, JS (Node) and Wasm (Node)
  • compileKotlinIosArm64 / compileKotlinIosSimulatorArm64 / compileAndroidMain / compileKotlinJs / compileKotlinWasmJs
  • Windows, real machine: examples/location-demo — NotDetermined → Foreground, cached one-shot fix, 3 live updates ~1 s apart
  • Web, headless Chrome over CDP: granted path (one-shot fix + 3 live updates as the emulated position moves) and denied path (Denied → LocationException(AuthorizationDenied))
  • nucleus.native-module still builds a JVM JNI module (:fs-watcher:jar); location-jvm.jar carries both Windows DLLs + the manifest
  • macOS: packaged location-demo (runDistributable) — prompt, one-shot, updates
  • Linux: portal path (GNOME / KDE), GeoClue fallback without a portal; Flatpak stays on the portal
  • Android device / emulator: coarse vs fine prompt, background request, "don't ask again"
  • iOS device / simulator
  • CI: Android SDK 37 on the Ubuntu runner; iOS klib cross-compilation on Linux

Desktop through a Rust JNI bridge (Windows Geolocator, macOS Core Location,
Linux XDG portal / GeoClue), Android LocationManager, iOS Core Location and
the browser Geolocation API for js / wasmJs, behind one LocationProvider.

nucleus.native-module now wires the jvm() target of multiplatform modules,
and detekt covers their source sets.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant