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.
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:
| Row | Shows |
|---|---|
| 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, Longitude | Signed 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 accuracy | The 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 |
| Clock | Local wall-clock time, ticking every second |
| Fix time | Timestamp 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:
| Undulation | Where | Ellipsoidal altitude reads | |
|---|---|---|---|
| Extreme low | −106 m | Indian Ocean, south of Sri Lanka | ~106 m too high |
| Extreme high | +85 m | Near New Guinea | ~85 m too low |
| Measured here | +0.59 m | Southern Victoria | 0.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:
- QNH needs the fix, QFE does not. The barometer is a device sensor, so QFE keeps reading with location switched off entirely — it is visible in the failure state captures below, sitting under a screen of em dashes. QNH goes blank with the fix and returns with it.
- Only a mean-sea-level height will do. The ellipsoidal height the receiver reports otherwise is measured from a different surface, out by up to a hundred metres, which is twelve hectopascals of error. It is not quietly substituted: on a device that never reports an MSL height, asking for QNH says so instead of showing a number that is wrong.
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.
| Mode | Top of scale | |
|---|---|---|
| Boat | 50 kts | |
| Car | 160 km/h | |
| Light aircraft | 200 kts | |
| Jet | 500 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.
| Mode | km/h | mph | kts | m/s |
|---|---|---|---|---|
| Boat | 0–100 by 20 | 0–60 by 10 | 0–50 by 10 | 0–30 by 5 |
| Car | 0–160 by 20 | 0–100 by 20 | 0–100 by 20 | 0–50 by 10 |
| Light aircraft | 0–400 by 50 | 0–250 by 50 | 0–200 by 25 | 0–120 by 20 |
| Jet | 0–1000 by 200 | 0–600 by 100 | 0–500 by 100 | 0–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.
| Tap | Cycles |
|---|---|
| The mode icon, top left | boat → car → light aircraft → jet |
| The speed numeral or its caption | km/h → mph → kts → m/s |
| The bearing on the rose, or the track row label | true ↔ magnetic |
| The altitude row label | mean sea level ↔ WGS84 ellipsoid |
| The altitude or accuracy values | metres ↔ feet |
| The pressure row label | QFE ↔ QNH |
| The pressure value | hPa ↔ inHg |
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 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
- Speed, course, altitude and both accuracy figures are all optional in a GPS fix. When one is absent, its field renders as an em dash and its needle parks at the scale start. No zero is substituted and no previous value is carried forward.
- Before the first fix the status line reads
ACQUIRING FIX. After a fix goes stale it readsNO FIX. Five seconds without an update blanks every GPS-derived field. - A receiver that has gone quiet is restarted every ten seconds while
NO FIXis showing, rather than being left dead. - Location services switched off shows
LOCATION SERVICES OFF. A refused permission replaces the readout entirely with an explanation and a button to the app’s system settings page. - The status line is hidden only while a fix is current and healthy.
- Course over ground is meaningless at rest, so below 1 km/h the bearing reads an em dash and its needle is hidden rather than left to jitter around the rose. The unit stays: it is a choice, not a reading.
- QNH with no mean-sea-level height behind it reads an em dash, and the barometer's own reading is never passed off as a reduced one.
ACQUIRING FIX, before the first fix.
LOCATION SERVICES OFF.
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:
| Condition | Speed uncertainty | Bearing uncertainty | Horizontal accuracy |
|---|---|---|---|
| Outdoors, driving | 0.09 m/s | 0.5° | 3.8 m |
| Indoors, stationary | 1.50 m/s | 45° | 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.
| API | Android | Released | What it brings here |
|---|---|---|---|
| 24 | 7.0 Nougat | August 2016 | The floor. Below this the app will not install at all |
| 26 | 8.0 Oreo | August 2017 | Per-figure uncertainties: speed accuracy, bearing accuracy, vertical accuracy |
| 34 | 14 | October 2023 | Height above mean sea level, and therefore QNH |
| 35 | 15 | 2024 | What 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
| Target | net9.0-android, API 24 (Android 7.0) to API 35 (Android 15) |
| UI | .NET MAUI |
| Gauges | DevExpress.Maui.Gauges 25.1.12 |
| Other packages | none |
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 / .cs | The whole screen and its state |
GpsReceiver.cs | LocationManager wrapper, emits a GpsFix |
GpsQuality.cs | Decides whether a reading is trustworthy |
GpsFormat.cs | Cardinal points and display formatting |
Units.cs | Conversions, suffixes and dial layouts |
PressureSensor.cs | Barometer, 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:
- 1 Hz update cadence sustained across a drive
- The em-dash rules firing on genuine fixes that carried no speed
- At-rest track suppression, on the exact fix where speed crossed below 1 km/h
- The magnetic conversion, checked against a known declination for the test location
- The sea level reduction, against the standard atmosphere and the thirty-feet rule
- The stale-fix path, and recovery afterwards
- Phantom speed suppressed while parked indoors
- Unit switching, and persistence across a force-stop and relaunch
- The barometer reading against an otherwise blank GPS readout
- Both orientations, and the permission-denied and services-off states
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.