No description
  • Go 86.1%
  • C 9.2%
  • Java 4%
  • Makefile 0.7%
Find a file
Sergio Rubio 6f3c787f43 Android: ask the fused provider, with a quality
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.
2026-10-06 13:58:41 +02:00
assets vibecoded :stamp: 2026-10-01 15:42:54 +02:00
cmd/gogps rename package 2026-10-06 13:25:07 +02:00
internal Android: ask the fused provider, with a quality 2026-10-06 13:58:41 +02:00
.gitignore Android backend 2026-10-06 12:47:10 +02:00
go.mod rename package 2026-10-06 13:25:07 +02:00
go.sum initial release 2026-10-01 15:22:28 +02:00
gogps.go Android: ask the fused provider, with a quality 2026-10-06 13:58:41 +02:00
gogps_test.go Android: ask the fused provider, with a quality 2026-10-06 13:58:41 +02:00
Makefile Android backend 2026-10-06 12:47:10 +02:00
README.md Android: ask the fused provider, with a quality 2026-10-06 13:58:41 +02:00

gogps

Vibecoded: some or all of this code was written by AI and accepted on vibes

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 WithMaxRadius or check Fix.Accuracy.
  • Altitude, Speed and Heading are 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.
  • Fixes drops 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_LOCATION in the manifest, plus ACCESS_COARSE_LOCATION (fine alone is enough for AccuracyNeighborhood and 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 types
  • internal/geoclue, internal/portal: the two D-Bus clients
  • internal/android: the LocationManager client, its Java shim and dex
  • internal/bustest: fake GeoClue and portal services on a private dbus-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.