SpeedGps

A single-screen GPS readout for Android, meant to be glanced at while moving.

What it is

SpeedGps shows speed, track, position, barometric pressure and time on one page. It is an instrument, not a navigation app: there are no maps, no route guidance, no trip logging and no settings screen.

The design follows a single rule, and most of what is unusual about the app follows from it:

The app must never show a plausible-looking but wrong number. Where a reading is missing, stale or untrustworthy, it shows nothing at all.

That is harder than it sounds. A GPS receiver will happily report a speed while you sit parked in a garage, and an app that trusts it will show you 13 km/h. Everything under Failure states and Signal quality exists to stop that reaching the screen.

The display

Portrait stacks the two dials above the readout block. Landscape is not the same layout turned sideways: it drops the compass dial altogether and states the track as a TRACK row above the readout instead. Sharing a column with the text leaves the rose too small to read, and a bearing loses nothing by being a number. The layout switches automatically. Dark throughout, high contrast, no title bar and no navigation bar — it is meant to be readable in a car in daylight.

SpeedGps in portrait: speed dial above a compass rose, above a monospaced readout block.
Portrait, with a fix. The status line is hidden because the fix is current and healthy. The compass reads an em dash because the phone was standing still when this was taken, which is the at-rest rule working.
SpeedGps in landscape: speed dial on the left, mode icon, status, TRACK row and readout on the right, no compass dial.
Landscape: the dial takes the left, the mode icon heads the right-hand column, and the compass rose becomes a TRACK row. The bottom of the scale is lined up with the last row of the readout, measured at runtime rather than fixed, so it holds on any screen shape.

Speed

A radial dial fills the top of the screen, with the value repeated as a large numeral in the centre of the face. The needle is animated so it moves smoothly between readings.

If speed exceeds the top of the scale the needle pins there, but the numeral keeps showing the true value. The dial never caps the number.

Track

A compass rose labelled at the eight principal points, with the numeric bearing, the unit it is quoted in and its 16-point name in the middle, for example 047 °T NE.

The word is chosen carefully. This is track — course over ground, the direction you are actually travelling — and not heading, which is the direction the vehicle is pointing. A boat crabbing across a tide and an aircraft in a crosswind both point somewhere other than where they are going, and GPS can only ever know the second of those. At rest neither exists, which is why the reading blanks below 1 km/h.

The needle is deliberately not animated: animating it makes the needle sweep the long way round the dial whenever the bearing wraps between 359° and 0°.

The dial appears in portrait only. Landscape shows the same track as text, since a rose squeezed beside the readout is too small to be worth the space it takes.

Magnetic and true

A bearing means nothing without saying which north it was measured from, so the unit says so: 047 °T is measured from true north, 036 °M from magnetic. Tapping switches between them. Out on the rose there is no label to hang the unit on, so the bearing carries it and the tap is on the bearing; in landscape it goes in brackets on the row label, TRACK (°T), and the tap is on the label — the same arrangement as the altitude datum and the pressure level.

GPS works in true north. Magnetic north is somewhere else entirely, by an amount that depends on where you are standing and drifts from year to year: about 11½° east in southern Australia, more than 20° west off the north Atlantic, and steepening towards the poles. The conversion uses the World Magnetic Model already carried in Android (GeomagneticField), evaluated at the fix’s own latitude, longitude, altitude and time. No magnetometer is involved, so the figure is not disturbed by the steel around you or by where the phone happens to be sitting.

Switching reference turns the whole rose over, not just the number. In magnetic the card is read from magnetic north, so the needle, the digits and the 16-point name are all the same bearing — the needle cannot be pointing at one thing while the number says another.

There is no case where the app has a bearing but cannot give you it in magnetic. The World Magnetic Model is defined over the whole planet and every fix carries the position and time it needs, so the declination always resolves. Magnetic is available whenever a bearing is.

Which north you are working in is a choice rather than a reading, so it stands whether or not there is a bearing behind it: standing still the rose reads — °T, and the row label still says which north it would be quoting. That is the same rule that keeps — m on a blank altitude, and it keeps the tap that changes it worth making when there is nothing on screen to change.

Landscape has the width for more, so the value also states how well the receiver knows the bearing: 047 (±2°). That is the receiver’s own uncertainty for this fix, never displayed as better than 1°, omitted rather than guessed at when the fix does not report one, and gone entirely when there is no bearing for it to describe.

The readout

A plain monospaced block, no gauges:

RowShows
Track (°T)
Track (°M)
Landscape only, in place of the compass dial. The label carries the unit, the value the bearing and its uncertainty
Latitude, LongitudeSigned decimal degrees to six places
Altitude (MSL)
Altitude (WGS84)
Height above mean sea level, or above the ellipsoid. The label says which — see below
H accuracy, V accuracyThe receiver’s own estimate of its error
Pressure (QFE)
Pressure (QNH)
Barometer, where the device has one, as measured or reduced to sea level. The label says which — see below
ClockLocal wall-clock time, ticking every second
Fix timeTimestamp of the fix itself, so a stale fix is obvious

Altitude and sea level

Altitude needs a surface to be measured from, and there are two candidates. The WGS84 ellipsoid is a smooth mathematical figure, the same one the latitude and longitude are referenced to. Mean sea level follows gravity, which is lumpy: the sea stands higher over dense rock and lower over less dense rock, and the surface that results — the geoid — departs from the ellipsoid by a surprising amount.

That departure is the geoid undulation, and across the whole planet it spans about 192 metres:

UndulationWhereEllipsoidal altitude reads
Extreme low−106 mIndian Ocean, south of Sri Lanka~106 m too high
Extreme high+85 mNear New Guinea~85 m too low
Measured here+0.59 mSouthern Victoria0.6 m too high

The worst spot is open ocean, so nobody stands there; on land the practical range is roughly −90 m in southern India and Sri Lanka to the +85 m of the New Guinea highlands. Either way it is far larger than most people expect from a figure the receiver reports without comment.

Two things make it less alarming than 106 metres sounds. It is a bias, not noise: the undulation is fixed for a given place and varies smoothly, well under a metre per 100 km, so altitude changes are unaffected — climb 100 m and the reading rises by 100 m wherever you are. And it happens to be negligible here: the 0.59 m measured on this device is well inside its own vertical accuracy.

The app shows mean sea level by default, since that is what people mean by altitude, and tapping the row label switches to the ellipsoid and back. The label always states which one is on screen, so the two are never confused.

Android has only supplied sea-level altitude since API 34, and only once a fix carries it. Where it is unavailable the row falls back to the ellipsoidal figure and relabels itself to ALTITUDE (WGS84) rather than presenting an ellipsoidal height as MSL. Asking for a datum the device cannot supply says so instead of silently doing nothing.

QFE and QNH

The barometer measures the pressure of the air the phone is sitting in. In aviation that is QFE: an altimeter set to it reads zero on the ground. QNH is the same air reduced to mean sea level, the setting that makes an altimeter read height above the sea rather than above the field, and the number an airfield passes you. Tapping the row label switches between them, and the label always states which one is on screen.

The reduction is not a second measurement, it is arithmetic over the standard atmosphere — 15 °C at sea level falling 6.5 °C per kilometre — applied to the height from the GPS fix. It lands where the rule of thumb says it should: at 300 m the app works out one hectopascal for every 26.7 feet, against the 30 feet pilots carry in their heads.

Two things follow from where that height comes from:

Treat it as a readout, not an altimeter setting. A phone barometer is good to something like a hectopascal in absolute terms, and the GPS height it is reduced through carries a few metres of its own error. It is close enough to sanity-check an official QNH, not to stand in for one.

Modes

A boat, a car and an aircraft want the same needle to cover very different ranges. The mode, shown as an icon at the top left and cycled by tapping it, sets how far the scale goes. It changes nothing else: the readings, the rules and the units are all unaffected.

ModeTop of scale
Boat50 kts
Car160 km/h
Light aircraft200 kts
Jet500 kts

The mode is an icon rather than a word. It reads faster at a glance, and it leaves the status line the width it needs — spelling out “FLYING SLOW” squeezed that column until ACQUIRING FIX wrapped onto three lines. The two aircraft are told apart by wing shape rather than by any label: straight wings and a propeller for the slow one, swept wings for the fast.

Mode and unit are separate choices, so any mode can be read in any unit — a boat in km/h if that is what you think in. That makes sixteen possible dials, which are not tabulated in the code but derived: the mode's ceiling is converted into the chosen unit, then rounded up to the first interval that labels the face without crowding it. The maximum is always a round number in whatever unit is on screen, and the interval always divides it evenly.

Modekm/hmphktsm/s
Boat0–100 by 200–60 by 100–50 by 100–30 by 5
Car0–160 by 200–100 by 200–100 by 200–50 by 10
Light aircraft0–400 by 500–250 by 500–200 by 250–120 by 20
Jet0–1000 by 2000–600 by 1000–500 by 1000–300 by 50

Every one of those is checked: the scale must reach the mode's ceiling, the interval must be a whole number, the face must carry between five and nine labels, and the scale must not overshoot the ceiling by more than a third, or the needle would spend its life in the lower part of the dial.

The mode label is always on screen, which has a useful side effect: the top row never collapses, so the dials below no longer shift when the status line appears or clears.

Changing units

There is no settings screen. The mode, the units, the altitude datum and the north that track is measured from are all changed by tapping the thing itself, and every choice is remembered between sessions.

TapCycles
The mode icon, top leftboat → car → light aircraft → jet
The speed numeral or its captionkm/h → mph → kts → m/s
The bearing on the rose, or the track row labeltrue ↔ magnetic
The altitude row labelmean sea level ↔ WGS84 ellipsoid
The altitude or accuracy valuesmetres ↔ feet
The pressure row labelQFE ↔ QNH
The pressure valuehPa ↔ inHg
The portrait screen with seven tap regions outlined and numbered: the mode icon, the speed numeral and caption, the track on the compass face, the altitude row label, the three distance rows, the pressure row label, and the pressure value.
The seven tap regions. Each rectangle is the real target, measured off the running app with Android’s layout bounds turned on, rather than an approximation drawn over the artwork.

Conversion happens only at the point of display. Everything upstream stays in the receiver’s own metres and metres per second, so changing the displayed unit cannot move any of the thresholds below. The at-rest track rule, for instance, is held as its equivalent in m/s rather than as “1 km/h”, so selecting mph does not quietly change the speed at which the track needle disappears.

The same screen showing miles per hour, feet and inches of mercury.
The same screen in mph, feet and inches of mercury, and with the pressure reduced to sea level. The dial is rescaled to 0–100 rather than relabelled, the accuracy rows follow altitude into feet, and the pressure label says QNH because that is what is being shown.

The unit is shown even when there is no reading behind it — an absent altitude reads — m, not — so the selected unit stays legible and tapping gives feedback before the first fix arrives.

Failure states

Every GPS field an em dash, with ACQUIRING FIX on the status line.
ACQUIRING FIX, before the first fix.
Every GPS field an em dash, with LOCATION SERVICES OFF on the status line.
LOCATION SERVICES OFF.
A full-screen panel explaining that location permission was denied, with a button to open app settings.
Permission refused. The panel covers the dials entirely.

Note the pressure reading surviving in the first two: it comes from the barometer, not the receiver. Both come from the device rather than the receiver, so neither can go stale for the reason a position can. Losing the GPS fix tells you nothing about what time it is.

Signal quality

A receiver with a poor view of the sky still produces fixes — it just produces bad ones. Measured indoors on a stationary phone: ten satellites at a mean C/N0 of 12 dB-Hz, against the 35–45 that usable GPS needs. The receiver was solving off multipath reflections, its position was wandering, and wandering position is velocity as far as the solver is concerned. It reported 3.69 m/s — 13.3 km/h — on a bearing of 185°.

Rather than invent a rule about which speeds look believable, the app asks the receiver how well it knows its own answer. Android publishes an uncertainty beside each figure, and the good and bad cases are an order of magnitude apart:

ConditionSpeed uncertaintyBearing uncertaintyHorizontal accuracy
Outdoors, driving0.09 m/s0.5°3.8 m
Indoors, stationary1.50 m/s45°57.4 m

Speed is shown when its uncertainty is within 1 m/s, and track when its bearing uncertainty is within 25°. A bearing uncertain to 45° is wider than the gaps between the points of the compass, so the needle would be pointing at the wrong one.

Between those limits and a marginal bound, a figure is accepted only if horizontal accuracy is within 10 m. That distinguishes a receiver whose Doppler solution is still settling from one that is guessing: while acquiring outdoors, a fix arrived with a speed uncertainty of 1.03 m/s but a horizontal accuracy of 4.7 m, and suppressing it made the readout flicker for no good reason.

Deciding this per fix, rather than by counting bad fixes in a row, is deliberate. Hysteresis would have to let one wrong value reach the screen before it could suppress anything.

Suppressed figures render as em dashes through the same path as figures the receiver never supplied. In both cases the honest answer is that the value is not known.

Decisions worth knowing

Each of the following cost real measurement to establish, and each looks like an easy improvement until you try it.

The location source

Location comes from Android’s LocationManager directly, not from Microsoft.Maui.Devices.Sensors.Geolocation.

The MAUI implementation attaches a 50 metre distance filter to every accuracy level, and GeolocationListeningRequest offers no way to clear it. Android therefore withholds updates until the device has travelled 50 m:

$ adb shell dumpsys location
com.companyname.speedgps  Request[@+1s0ms HIGH_ACCURACY, minUpdateDistance=50.0, ...]

That is fine for a mapping app and useless for a speed readout. Standing still, exactly one fix ever arrives. Walking, one arrives every 36 seconds. Driving at 100 km/h it would look fine and drop to NO FIX at every traffic light.

The app now asks the GPS provider for one fix per second with no distance filter at all, and the same command reports no minUpdateDistance. A walk outside logged 41 consecutive fixes with gaps between 987 and 1010 ms.

Android’s HasSpeed / HasBearing / HasAltitude flags are a second benefit: they distinguish “no speed reported” from “genuinely zero”, which the MAUI API could not. That distinction occurs in practice — mid-walk, two consecutive fixes arrived carrying no speed at all. Permissions, keeping the screen awake and opening the settings page still go through Essentials.

The centre readouts

The numerals in the middle of each dial are overlaid in the gauge’s grid cell, not set as RadialGauge.Content. The gauge paints its scale and indicators over its own content, so the needle hub buries anything placed there. Declaring the readouts after their gauge makes MAUI paint them last.

The update rate

One fix per second is a hardware floor, not a setting. Asking the provider for updates as fast as it can manage was measured as no different: 74 of 76 gaps came back at ~1000 ms either way, because the GNSS chipset solves at 1 Hz.

The steppiness under braking is therefore physics, not lag. Across 57 fixes at road speed the median change between displayed values was 0.8 km/h; the 7.1 km/h maximum landed during hard braking, which is about 2 m/s² sampled once a second. Smoothing it would mean drawing numbers between fixes that the receiver never reported.

No inertial fallback

The phone has an IMU, and it cannot help with speed. Deriving speed from an accelerometer requires knowing tilt to better than about 0.1°, because gravity is 9.81 m/s² and any attitude error leaks straight into horizontal acceleration. A 1° error is 6 km/h of drift after ten seconds, growing without bound.

Navigation apps survive tunnels not through better sensors but by map-matching onto a known road, which collapses position to one dimension and supplies heading from the road geometry. This app has no map to do that with. Reading real wheel speed from the vehicle bus over OBD-II is the only approach that would genuinely work.

No temperature

The hardware has no ambient thermometer — no ambient_temperature, no relative_humidity. Ambient sensors disappeared from phones after about 2014. What remains are internal thermal sensors for battery and CPU throttling, which measure the device’s own heat rather than the air, and Android does not expose them as ambient sensors. Reporting one as “temperature” would be exactly the plausible-looking wrong number the app exists to prevent.

Android versions

The app installs on Android 7.0 and later, which is nine years of handsets, but two of the readings need a newer platform than that. Where the platform cannot supply something the app says so rather than inventing it, so an older phone loses features without ever showing a wrong number.

APIAndroidReleasedWhat it brings here
247.0 NougatAugust 2016The floor. Below this the app will not install at all
268.0 OreoAugust 2017Per-figure uncertainties: speed accuracy, bearing accuracy, vertical accuracy
3414October 2023Height above mean sea level, and therefore QNH
35152024What the app is compiled against. It does not limit what can run it

Android 7.x: no per-figure uncertainty

The whole of Signal quality rests on the receiver stating how well it knows each figure, and those arrived in API 26. On Android 7.x they do not exist, so the app falls back to the one thing every version reports — horizontal accuracy — and requires it to be within 30 m before it will believe the speed or the track at all.

That is a blunter instrument than the per-figure test. It is enough to keep a phone sitting indoors from claiming 13 km/h, which is what the rule exists for, but it cannot tell a fix whose position is sound and whose Doppler solution is not. The V ACCURACY row reads an em dash permanently, because nothing is reporting it.

Before Android 14: no sea level

A GPS receiver measures height above the WGS84 ellipsoid, and converting that to height above mean sea level needs a geoid model. Android has carried one since API 34, reachable through Location.getMslAltitudeMeters(); before that there is no supported way to ask for it.

On an older phone the altitude row therefore reads ALTITUDE (WGS84) and will not switch — tapping the label explains why instead of silently doing nothing. QNH goes with it, since reducing pressure to sea level needs a sea-level height and will not accept an ellipsoidal one. The barometer still reads QFE, which is the figure it actually measures.

Hardware, not version

The pressure row is a question of fitting rather than of Android version: no barometer, no row, and nothing to tap. Most phones from the last decade have one. The magnetic declination is the opposite case — GeomagneticField has been there since API 1 and needs no sensor at all, so magnetic track works on every version the app runs on.

The published build carries arm64-v8a code only. That covers essentially every Android phone sold since about 2015, but not a 32-bit handset or an x86 emulator; those need a build with the extra architectures included.

Building

Targetnet9.0-android, API 24 (Android 7.0) to API 35 (Android 15)
UI.NET MAUI
GaugesDevExpress.Maui.Gauges 25.1.12
Other packagesnone

DevExpress 25.2 and later require .NET 10; 25.1.12 is the newest release that still supports net9.0-android.

dotnet build

dotnet publish -f net9.0-android -c Release \
               -p:RuntimeIdentifier=android-arm64 \
               -p:AndroidPackageFormat=apk -o bin/phone

The source

File
MainPage.xaml / .csThe whole screen and its state
GpsReceiver.csLocationManager wrapper, emits a GpsFix
GpsQuality.csDecides whether a reading is trustworthy
GpsFormat.csCardinal points and display formatting
Units.csConversions, suffixes and dial layouts
PressureSensor.csBarometer, where fitted

GpsFormat, GpsQuality and Units hold no UI types, so their rules can be checked in isolation.

What has been verified

Developed and tested against a Galaxy S23 Ultra running Android 16. Confirmed on the device rather than assumed:

One behaviour remains unexercised: the needle pinning above the top of the scale.

The quality thresholds are calibrated against two real conditions — a driveway and a drive. They separate with a wide margin, but a tunnel mouth or an urban canyon may sit somewhere that has not been measured. If speed ever blanks under open sky, the thresholds are one-line constants in GpsQuality, with the reasoning beside them.