If you aren't developer and/or just want to get an apk and use my server... You can do that here, assuming it's up (At time of writing global map is fetched, but no guarantees it's up to date map!).
Self-hosted, on-demand, end-to-end-encrypted GPS sharing.
[Android phone] --UDP+AES-GCM--> [Go relay] --WebSocket--> [Browser]
(sender, owns (viewer,
inner key) has URL
with key)
The relay never sees plaintext positions - inner AES-256-GCM key is in the URL fragment, which browsers don't send to servers. The Android sender mints the key per share session and pastes a URL like:
https://your-server/?id=<accessKey>#<encKey>
into a "current link" field you can copy and send to a friend.
- Go 1.21+ (for building the relay server) -
https://go.dev/dl - go-pmtiles (downloads the map data) - installed via Go
- Android Studio or
gradlewCLI, plus a phone you canadb installto. Docker works too if you'd rather not install the Android SDK. - A box the phone can reach. Your LAN desktop is fine for test; a VPS with TLS in
front of the HTTP port is the proper deployment. The phone is pointed at the
box by hostname or raw IP - neither is special. On a real deployment the
Android sender talks to UDP
8050directly, but the HTTP port (8049) stays bound to localhost / behind the firewall and is reached only via your TLS reverse proxy on:443.
That's it. No Docker (unless you opt in), no Kubernetes, no analytics, no third-party tile server at runtime.
You build everything on a workstation, then ship a single Go binary + a .env
- the PMTiles map to whatever box will host the server. Map data is by far the biggest piece, so it's worth pulling that step out into "download once on the server" if uploading 50 MB - 120 GB over your home upload is not your idea of fun.
git clone https://github.com/DubbaThony/location-share.git share
cd shareWant one command?
./dockerized_build.shruns steps 2-4 in one shot (generates the keystore on first run, builds the APK, refreshes the embed, compiles the Go binary) and drops everything under./out/ready to scp. Steps 5-7 (map + .env + deploy) still happen on the server. Requiresdockerandgoon the build host. The rest of this section spells out the same steps manually for when you want to mix-and-match.
The Android sender mints the per-session inner-encryption key, so the APK needs to be signed with a keystore you own - debug keys aren't upgrade-stable, and you'll want to install over the same key forever.
Customize defaults for your fork: create
client/Android.env(gitignored). Gradle merges it per-KEY overclient/Android.env.defaults(tracked schema). Copy the whole defaults file and edit, or just write the few KEYs you care about - both work.
Easiest path is the Docker flow (no host JDK / SDK install needed) - I build like so:
# One-time: generate the release keystore. Back this up - lose it and you
# can never push a signed upgrade to the same install.
mkdir -p ./keystore
printf '%s' 'YOUR-STORE-PASS' > ./keystore/storepass.txt
chmod 600 ./keystore/storepass.txt
docker build -f client/Android/docker/Dockerfile.keygen \
--build-arg KEY_ALIAS=share \
--build-arg DNAME='CN=DubbaThony Share, O=DubbaThony, C=PL' \
--secret id=storepass,src=./keystore/storepass.txt \
--output=./keystore \
client/Android/docker
# Release APK → ./out/share-release.apk
# Build context is client/ (not client/Android/) so the build can see
# client/Android.env.sample alongside the Android module. See the Dockerfile
# header for the reasoning.
docker build -f client/Android/docker/Dockerfile --target=binaries-release \
--secret id=keystore,src=./keystore/release.jks \
--secret id=keystore_pass,src=./keystore/storepass.txt \
--output=./out \
client/If you'd rather build natively (host JDK + Android SDK):
cd client/Android
./gradlew assembleRelease # signs with your local debug key by default;
# configure signingConfig for a real release key.Either way, the APK you want to publish ends up named share-release.apk.
Don't run more than one
docker buildin parallel - they'd race on the shared Gradle cache and corrupt the build.
The Go binary embeds everything under server/static/ at compile time -
viewer HTML/JS, landing page, and the APK that the landing page's Get .APK
button serves. The single command refreshes all of it:
# From repo root:
rm -r server/static && cp -rp web/static server/static
cp ./out/share-release.apk server/static/pl.dubba.share.apkThe APK filename is hard-coded to pl.dubba.share.apk so debug builds (which
ship as pl.dubba.share.dev) can never overwrite the release download.
cd server
go build -o share-server .That's the only thing you ship to the server box. Frontend, APK, and viewer
code are baked into it via embed.FS.
go-pmtiles is the tool that pulls map tiles from Protomaps' planet build.
(thank you Protomaps <3)
go install github.com/protomaps/go-pmtiles@latest
# Make sure $(go env GOPATH)/bin is on PATH - usually $HOME/go/bin.Then pick a region:
cd map
./bin/download-map.sh list # see all available regions
./bin/download-map.sh poland # by name, substring match works
./bin/download-map.sh europe # bigger
./bin/download-map.sh krakow # tiny, ~50 MB - good for first testThe file lands at map/map.pmtiles. Sizes range: krakow ~50 MB,
poland a few GB, europe tens of GB, world ~120 GB (script will ask "are
you sure?" for that one). If your bbox isn't in the list, pass raw coords:
./bin/download-map.sh "minLon,minLat,maxLon,maxLat".
Continent scale? Pass
MAP_RESILIENT=1(or-e MAP_RESILIENT=1ondocker run). go-pmtiles' default packs the whole job into a single HTTP/2 stream that stays open for hours and dies on any transient peer error. Resilient mode flips on--overfetch=0(many small requests) plus parallel threads, so each individual request is small and short-lived. No retry-from-failure - that's upstream's gap to close.
World scale? The
worldkeyword bypasses go-pmtiles entirely and downloads the planet PMTiles file directly withwget -c. Resume works for free - if the stream dies mid-download, rerun the same command and it picks up at the byte offset of the partial file. The script also prints the exactwget -ccommand so you can resume manually outside the container if you'd rather.MAP_RESILIENThas no effect forworld.
Docker alternative if you don't want Go on the host:
docker build -t share-mapdl ./map/bin docker run --rm -it -v "$(pwd)/map:/output" share-mapdl poland # Continent / world: docker run --rm -it -e MAP_RESILIENT=1 -v "$(pwd)/map:/output" share-mapdl world
You can run this step either on the workstation (and upload the .pmtiles
later) or directly on the server box. Doing it on the server is usually saner
for anything larger than krakow - saves a long upload over a residential
link.
server/.env.sample is the template:
UDP_PORT=8050
API_LISTEN=0.0.0.0:8049
LOG_LEVEL=debug
TRACES_IN_LOGS=false
MAP_LOCATION=./map
ADMIN_CONTACT=somebody@example.com
METERED_SOCKET=true
Copy it next to the binary as .env and edit:
UDP_PORT- the port the Android sender talks to. Default8050.API_LISTEN-<bind-ip>:<port>for HTTP. Default listens on all interfaces, port8049. Put a TLS reverse proxy in front for real deployments (see below).LOG_LEVEL-trace/debug/info/warn/error/fatal/silent.tracewill dump per-frame hex; useful for debugging, noisy in prod.TRACES_IN_LOGS- file:line caller info on every log line. Off in prod.MAP_LOCATION- directory that holdsmap.pmtiles. Relative to the binary's CWD.ADMIN_CONTACT- email address shown in the privacy policy (web page + in-app About screen, fetched from/gpdr-emailat runtime). Technically required by GDPR.METERED_SOCKET- whentrue, wraps the UDP socket in a metering layer that logs average rx/tx rates periodically (info-level when there's traffic, debug-level when idle). Negligible overhead, useful for "is anything actually flowing right now" eyeballing. Setfalseto disable.
Ship the binary, the .env, and the map data to your server. Layout:
/wherever/you/want/
├── share-server
├── .env
└── map/
└── map.pmtiles
Run it under whatever supervisor you like (systemd, tmux, screen...).
Two ports matter (default values, you can change in .env):
- UDP
8050- the sender protocol. Open this to the public internet (port-forward / firewall rule) so the phone can reach it. - TCP
8049- viewer page, PMTiles,/identity, APK download. On a real deployment this stays behind the firewall - bind it to127.0.0.1in.env(or leave it on0.0.0.0and drop it at the firewall) and put a TLS reverse proxy on:443in front of it. The public-facing surface ishttps://your-domain/, neverhttp://your-server:8049/directly.
For a LAN-only test you can skip the reverse proxy and let the phone talk to
http://<lan-ip>:8049/ directly - fine for "does the wire actually work" or
for VPN LAN setups, not for anything you'd share with someone outside the network.
The release APK you built in step 2 is already on the server (you bundled it
into the embed). Pull it from https://<your-server>/static/pl.dubba.share.apk
on the phone's browser and install - or adb install it locally:
adb install -r out/share-release.apkYou should see "Success" from adb. The app appears as Share location on
the launcher.
If you only want to iterate locally,
./gradlew installDebugfromclient/Android/installs the debug variant (which coexists with release aspl.dubba.share.dev).
- Open Share location on the phone.
- Tap the gear icon (bottom-right of the main screen).
- Set:
- Server host - your server's hostname or IP (e.g.
share.example.comor192.168.1.123) - Server port -
8050(or whatever you setUDP_PORTto) - API address -
https://share.example.com/orhttp://192.168.1.42:8049/(note: for http withouts, you need to enable it in advanced settings)
- Server host - your server's hostname or IP (e.g.
- Back on the main screen, toggle GPS on. Grant the location permission (must be Precise - the app refuses to share approximate location).
- Toggle Server on. Grant notification permission if asked.
- Rotate the dials to pick how long to share (1 min - ∞) and how often to send position fixes (1/60s - 10/s).
- Wait a few seconds for the status pill to go green. The "current link"
field will populate with a URL like
https://share.example.com/?id=abc...#def... - Tap the link to copy it. Paste into any browser on any device.
Open the link in any browser. You should see:
- A map of your downloaded region
- A red triangle marking the phone's position, rotated to direction of travel
- Speed / last-update / status in the top bar
- Bottom-right buttons: dark mode, follow-lock toggle, rotation toggle, zoom in/out
The lock toggle (default on) auto-recenters on every position update and disables manual panning. Unlock to look around without the camera jumping back.
Three ways, any one works:
- Toggle Server off on the phone
- Tap Stop on the foreground notification
- Let the share timer expire (whatever you set on the share-time dial)
The server sends a "publisher gone" message to every active viewer; they show "share ended - reload for new" and stop reconnecting.
The frontend and APK both live inside the Go binary's embed, so any change to
either means a fresh server/static/, a fresh go build, and a redeploy:
# From the repo root:
rm -r server/static && cp -rp web/static server/static
cp ./out/share-release.apk server/static/pl.dubba.share.apk
cd server && go build -o share-server .
# ...then xfer share-server to your box and restart it.The rm -r nukes the whole embed dir, so the APK copy has to happen after
the frontend refresh - otherwise you'll wipe the APK you just put there.
./map/bin/download-map.sh <region>Map data is served from MAP_LOCATION at runtime (NOT embedded), so updating
the .pmtiles on the server takes effect without a rebuild - copy the new
file into map/, that's it. Don't download into live file though as that
will break current web clients. Use mv instead.
Open the app → Gear icon → Debug view → Export. The system share sheet lets you mail/save/Drive/Signal the rolled-up log file (~64 KB max). Logs persist across app kills and survive a rotation when they exceed the cap. If you can't export it in given moment, there is also button to pause logger, so you can capture issue without ex. specifying email address etc. in given moment.
If your server is on your LAN, you're done - the phone and any browser on the
same network reach http://<lan-ip>:8049/ directly. For a server reachable
from the public internet you want:
- UDP
8050exposed. Port-forward (home router) or firewall-allow (VPS) the sender port - Android talks to it directly, no proxy possible (it's a custom UDP protocol, not HTTP). - TCP
8049NOT exposed. Bind it to127.0.0.1in.env(API_LISTEN=127.0.0.1:8049) and/or block it at the firewall. The HTTP port carries the viewer page, PMTiles,/identity, and the APK download - none of which should be served over plaintext on a public address. - TLS reverse proxy on
:443. Apache2, nginx, Caddy, or whatever you like, with a real certificate (feel free to use let's encrypt), proxying tohttp://127.0.0.1:8049/. Point the API address in the Android app athttps://your-domain/. Remember that the config of proxy must support websockets.
Use TLS for anything you'd actually share publicly.
The sender UDP port (
8050) doesn't ride TLS - the protocol does its own ECDH + AES-GCM handshake. That's the whole point of the/identityendpoint: the phone fetches the server's Ed25519 public key over the TLS HTTPS port (authenticated by your real cert), then uses it to verify the UDP handshake signature.
Assuming some minimal system, running nothing else but reverse proxy, ssh and relay
- 512M of memory
- amd64 1 core, anything at least as modern as Ivy Bridge (and not Atom-tier)
- 10G of disk for single-country map deployment
- 140G of disk for world map deployment
Phone says "GPS subscription is running with no fix" for a long time. You're indoors, under heavy foliage, or in a basement. Walk outside.
Viewer LED blinks red, label says "invalid key - check your URL".
URL got mangled (truncated, copy-paste split, browser auto-link removed the
fragment). Re-copy from the app - the fragment after # carries the
decryption key.
Viewer shows yellow LED with "gps off" forever. Phone toggled GPS off but kept the Server connection up. Toggle GPS back on.
Viewer shows yellow LED with "gps on, no fix" forever. Phone's GPS subsystem hasn't locked yet. Same situation as the first item.
Viewer says "share ended - reload for new". Sender disconnected gracefully. Open a fresh URL after they restart the share.
Viewer never connects (red LED, no progress).
Server-side or network-side problem. Check go run . log on the server for
incoming /bind/... requests. If you don't see them, the browser couldn't
reach the server - firewall, port-forward, or wrong host in the URL.
Phone log shows "server port unreachable - session unrecoverable". Server crashed/restarted while the sender was active. Toggle the Server switch off then on to mint a new session.
Map renders only water. Schema version mismatch between the downloaded PMTiles and the layer definitions. Re-download the map; if it persists, file a bug.
Map doesn't render at all (desktop only - phone works). WebGL is broken in your desktop browser. Usually a VM or software-only rendering. Try the phone instead.
share/
├── spec/proto.md # UDP wire format spec (frozen)
├── server/ # Go relay
│ ├── main.go # routes, port wiring
│ ├── protocol/ # outer crypto, frame types, handler
│ ├── publisher/ # bytes-in → fan-out → WebSocket
│ ├── ifaces/, state/, util/
│ ├── static/ # FRONTEND EMBED - copied from web/static + release APK
│ └── .env.sample # template for runtime config (UDP_PORT, API_LISTEN, etc.)
├── client/Android/ # Sender app
│ ├── app/ # main module
│ └── protocol/ # shared crypto + marshalling with server
├── web/ # Source-of-truth viewer frontend
│ └── static/ # HTML/CSS/JS - what gets copied to server/static
└── map/ # Map data + downloader (not embedded; served at runtime)
├── bin/download-map.sh # PMTiles downloader
└── map.pmtiles # downloaded artifact (gitignored)
Should this be four separate repositories? Probably. Is it? No. Move on.
Claude code was used in development.