- Go 86.1%
- C 9.2%
- Java 4%
- Makefile 0.7%
Devices with a fused provider are asked through it alone, since it combines the rest: every device from Android 12 on, and those with Google's location services before that. A fused provider with neither GPS nor a network provider under it never answers, as on a WiFi tablet without Google's services, so it does not count and the session fails with "no location provider" instead of waiting forever. From Android 12 the request carries a quality: high accuracy from street level up, so the fused provider still turns the GPS on, and balanced power below. The rename left six files unformatted; gofmt. |
||
|---|---|---|
| assets | ||
| cmd/gogps | ||
| internal | ||
| .gitignore | ||
| go.mod | ||
| go.sum | ||
| gogps.go | ||
| gogps_test.go | ||
| Makefile | ||
| README.md | ||
gogps
Location for Go programs on Linux phones and desktops, and on Android. On Linux it's D-Bus only: no cgo, no libgeoclue.
It talks to the same services GNOME Maps uses:
- GeoClue2 on the system bus. GeoClue does the heavy lifting: GPS (ModemManager, gpsd or an NMEA socket), WiFi and cell tower lookups, GeoIP.
- The XDG location portal on the session bus. That's the only way out of a Flatpak or Snap sandbox. The portal is a GeoClue client itself.
GNOME Maps picks one through libgeoclue's GClue.Simple: the portal when sandboxed, GeoClue otherwise. BackendAuto, the default, does the same and falls back to the portal if GeoClue isn't running. It won't retry through the portal after a refusal though. That would ask the user twice, which is rude.
Tested on Fedora 44 (GNOME) and a Fairphone 6 running postmarketOS edge with Phosh. Both backends, permissions and the location switch work there. GPS fixes are untested so far: the test phone has no SIM, so ModemManager never powers the modem up.
Library
import "code.rbel.co/rubiojr/gogps"
// Stream fixes.
s, err := gogps.Start(ctx,
gogps.WithDesktopID("org.example.App"),
gogps.WithMaxRadius(100), // drop GeoIP-grade guesses
gogps.WithStateHandler(func(st gogps.State) { log.Println("location", st) }))
if err != nil {
return err // errors.Is: gogps.ErrDenied, gogps.ErrDisabled, gogps.ErrUnavailable
}
defer s.Close()
for fix := range s.Fixes() {
fmt.Println(fix.Latitude, fix.Longitude, fix.Accuracy, fix.Description)
}
return s.Err()
// Or wait for one fix.
ctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
fix, err := gogps.Current(ctx, gogps.WithMaxRadius(100))
// Ask for access up front, without starting a stream.
err = gogps.RequestPermission(ctx, gogps.WithParentWindow(handle))
// What can this device do right now? (GeoClue only.)
acc, err := gogps.AvailableAccuracy(ctx) // AccuracyExact means a GPS source exists
busy, err := gogps.InUse(ctx) // another app is using location
| Option | Effect |
|---|---|
WithBackend |
BackendAuto (default), BackendGeoClue or BackendPortal |
WithDesktopID |
App ID GeoClue's config checks. Defaults to the executable name |
WithAccuracy |
Requested level, AccuracyExact by default |
WithDistanceThreshold, WithTimeThreshold |
Minimum movement or interval between updates |
WithMaxRadius |
Drop fixes less accurate than this many meters |
WithParentWindow |
Attach the portal's permission dialog to your window (wayland:… or x11:…) |
WithStateHandler |
Called when the session pauses or resumes |
A few things worth knowing:
- The first fix is usually cached or coarse. GeoIP can be 25 km off, so use
WithMaxRadiusor checkFix.Accuracy. Altitude,SpeedandHeadingare nil when unknown. GeoClue fills in speed and heading for WiFi fixes too, from the jitter between them. Don't trust those without GPS.- A GPS cold start on a phone modem can take minutes.
Fixesdrops the oldest queued fixes if you don't keep up. It never blocks the backend.
Permissions
Access is decided when a session starts. Start and RequestPermission return once the desktop, or the user, has answered.
On Linux, unsandboxed apps aren't asked at all, GeoClue and the portal both trust them. Only sandboxed apps get a per-app prompt. Android asks the user the first time, as it does for every app; see below.
| Sandboxed app (Flatpak, Snap) | Unsandboxed app | |
|---|---|---|
| Asked per app? | Yes, once, by the portal. The answer is stored and can be changed in the privacy settings | No |
ErrDenied |
The user said no | GeoClue's config disallows the desktop ID, or no GeoClue agent is running where one is required |
ErrDisabled |
Location services are switched off | Same |
There's no way to check permission without asking. GeoClue doesn't expose the agent's decisions and sandboxed apps can't read the permission store, so RequestPermission is the check.
Switching location services off during a GeoClue session pauses it (StatePaused) instead of ending it. GeoClue resumes it when they're back on, and WithStateHandler tells you about both.
The permission dialog shows the X-Geoclue-Reason key from your app's .desktop file. Ship one if your app is sandboxed.
Android
The same API reads Android's location providers. Android has no native location
API, so the backend goes through JNI and a tiny Java class, internal/android/Gogps.java,
loaded at run time from the dex embedded in the library. Nothing to add to your build.
What your app needs:
ACCESS_FINE_LOCATIONin the manifest, plusACCESS_COARSE_LOCATION(fine alone is enough forAccuracyNeighborhoodand up; below that the coarse one does).- The Java VM and activity, once, through
WithAndroidActivity. With Fyne:
var vm, activity uintptr
driver.RunNative(func(c any) error {
if ac, ok := c.(*driver.AndroidContext); ok {
vm, activity = ac.VM, ac.Ctx
}
return nil
})
s, err := gogps.Start(ctx, gogps.WithAndroidActivity(vm, activity))
Start asks the system for permission the first time and returns ErrDenied when
the user says no. Devices with a fused provider (Android 12 and later, or Google's
location services before that) are asked through it, for high accuracy from
AccuracyStreet up and balanced power below; a device without one is asked for GPS
from street level up and the network provider (WiFi and cell towers) always. Fixes
say which provider answered in Description. Switching location off pauses the
session, as GeoClue does.
Android 8 or later. Updates arrive while the app is in the foreground; recording in the background would need a foreground service, which this library can't declare.
make dex rebuilds internal/android/gogps.dex from the Java source; it needs the
Android SDK (javac, android.jar and d8).
CLI
go install code.rbel.co/rubiojr/gogps/cmd/gogps@latest
gogps # stream fixes until Ctrl-C
gogps -once -max-radius 100 # one fix at least 100 m accurate (2m timeout)
gogps -backend portal -n 3 -json
gogps -status # available accuracy, in use, permission
make arm64 builds a static bin/gogps-linux-arm64 for phones.
postmarketOS
GPS gets to GeoClue in one of two ways, depending on the device:
- ModemManager modems (PinePhone and friends) through GeoClue's
[modem-gps]source. - Some Qualcomm phones through
gnss-share, which serves NMEA on a socket GeoClue reads with its[network-nmea]source. Haven't tried this one.
Heads up: available accuracy: exact in gogps -status only means GeoClue sees a GPS-capable source, not that it's getting fixes. A modem ModemManager didn't enable still counts, and a missing SIM is enough for that (mmcli -m 0 shows failed reason: sim-missing). mmcli -m 0 --location-get tells you whether NMEA data is actually flowing.
Layout
The root package is the whole public API. Everything else lives under internal/:
internal/backend: fix decoding, error classification, shared typesinternal/geoclue,internal/portal: the two D-Bus clientsinternal/android: the LocationManager client, its Java shim and dexinternal/bustest: fake GeoClue and portal services on a privatedbus-daemon, for tests
Development
make test # go test -race -cover ./...
make lint # go vet + staticcheck
make dex # rebuild the Android shim after editing Gogps.java
The bus tests skip when dbus-daemon is missing or won't take connections.