A single-user, fully offline Android app for monitoring and maintaining trails where there is no signal. Map, live GPS, tasks with photos, route recording, structures & inspections, and segment-timed work — the whole Trailkeeper field app, but with no backend, no account, no sync. Everything lives on the phone. Maps are pre-downloaded to phone storage. The only bridge to the outside world is file export/import (GPX, GeoJSON, CSV, a backup zip) and device-to-device sharing of a single project, task, route, trail, or structure through the normal OS share sheet.
Derived from Trailkeeper. Full plan
and rationale: docs/BLUEPRINT.md.
GPL-3.0 — see LICENSE.
No Play Store. Download the APK and open it (allow "install unknown apps"):
- Release page: https://github.com/AndSni/TrailkeeperOffgrid/releases/latest/download/TrailkeeperOffgrid.apk
applicationId com.trailkeeperoffgrid.app — matches the com.<name>.app
convention SharpRight (com.sharpright.app) and XCMr (com.xcmr.app)
already use on Play; the internal code package stays
com.asnidev.trailkeeperoffgrid (Gradle namespace, untouched by this).
v0.3.10 — F-Droid build fix:
- Disabled AGP's automatic "dependency metadata" block
(
dependenciesInfo { includeInApk = false; includeInBundle = false }) in release builds. AGP embeds this Play Console reporting block in every release APK/AAB by default since 7.4 - unused and unwanted here, since this app isn't distributed through Play, and F-Droid's security scanner correctly flags any unexpected extra block in a signing payload. Caught during F-Droid submission review; no user-facing changes.
v0.3.9 — device-to-device sharing:
- Share a project, task, route, trail, or structure straight to
another phone through the OS share sheet (Bluetooth, Quick Share,
email, whatever's already on hand). A Share icon on the relevant
screen/row builds a small
.tksharebundle — a zip of JSON tables plus any referenced photos — via the newShareBundle.kt. Opening a received bundle (direct share, or "Open with" from a file manager) lands on an "Import share" preview before anything touches the database; when the shared item's project isn't on this device yet, you're asked to attach it to an existing project or create a new one, rather than a stub project appearing silently. - Restore backup was silently failing — fixed.
Backup.kt's whole-workspace restore used a Gson generic-deserialization trick (Array<T>::class.javafor a reified type parameter) that, on-device, handed Gson a type it resolved to rawLinkedTreeMapelements instead of the real entity — every restore threw and failed. Found while building the sharing feature above (which briefly carried the same bug); both now useTypeToken.getParameterized, Gson's documented approach for this. - Pinned the Gradle wrapper download to its published SHA-256
(
distributionSha256Sum), closing off a supply-chain tampering vector in the build itself — part of F-Droid submission prep.
v0.3.7 — map-scope correctness and usability pass:
- "This project" map filter actually filters now. It used to dim
trails/structures via a padded bounding box loose enough that almost
nothing was ever excluded. Replaced with a real rule: a trail/structure
counts as this project's if it was created here (new nullable
projectIdontrails/structures, Room v3→v4 migration) or falls within a distance of one of this project's own tracks/trails, computed with actual point-to-line geometry rather than a bounding box. That distance is now user-configurable (Settings → Map), since "close enough" depends on how dense the local trail network is. Older trails/structures that predateprojectIdcan be manually attached to a project from their detail sheet ("Add to this project"). - Map marker size, also configurable in Settings → Map — the default task/structure/report dots were too small to comfortably tap.
- Move/place-a-point screen (move a task or structure, add a structure) now draws the project's trails/tracks as context instead of a blank map.
- A moved task's new position now shows up immediately on the map. The map fully remounts on the round-trip through the move screen, and its one-time async style-load callback could lock in a stale data snapshot from the instant of remount if it raced the (near-instant) database write — the map now always reads the latest data at push time instead of whatever was true when it was first created.
- Settings → Offline maps no longer permanently shows the Baltic quick-preset list; it shows your downloaded regions plus "Browse all countries", with the one non-country quick region folded into that browse screen.
- Settings → About gained a short note from the developer and a Ko-fi support link.
v0.3.6 — GPX, photos, and theming pass:
- GPX import split into separate Route-tab and Trails-tab buttons,
each importing the whole file as that type — the previous per-tag
auto-detection failed on nearly every real-world GPX file, which is
almost always written as
<trk>regardless of what the line conceptually is. - Camera capture for task/report photos — "+" now offers "Take photo" or "Choose from gallery" instead of gallery only.
- Task list card rebuilt into a clearer 3-row layout (urgency / title / status + photo + comment + mark-done), with an urgency-coloured open status.
- Light theme + 5 accent colours, user-selectable in Settings — the app was dark-only before.
- Task/structure/trail detail sheet now opens fully expanded and scrolls, instead of resting half-height with its bottom buttons crowded under the drag handle.
- Hold-to-delete added for recorded routes and trails, matching the existing project-card gesture.
v0.3.5 — security and cleanup pass:
- Backup restore hardened. Restoring a workspace
.zip(Settings → Restore backup) trusted the archive's internal file paths and JSON content without checking them: a crafted zip entry name could write outside the app's ownphotos/folder (a classic "Zip Slip" path traversal), and a craftedphotosJson.urlcould point the photo grid's read — or its hold-to-delete gesture — at any file the app can reach. Both are now rejected unless they canonically resolve inside the app's own photos directory (Backup.kt,LocalStore.kt,TaskPhotos.kt). - Discussion → Notes. The project-wide "Discussion" tab — a leftover from multi-user Trailkeeper that never fit a single-user offline app — is gone. The per-task thread stays, since it's genuinely useful as a running note log on a task; it's relabeled "Notes" throughout to match what it actually is.
- Keyboard fix. The Notes composer didn't account for the on-screen
keyboard under edge-to-edge (
enableEdgeToEdge()opts out of the system's automatic resize behavior), so typing a note could shove the input field and message list around, most visibly on a short/empty thread. It now reserves space for the keyboard (Modifier.imePadding()) like the rest of the app expects. - Licensed under GPL-3.0 (see
LICENSE) and release APKs are no longer committed into the repo — see.github/workflows/release.ymland the F-Droid note below.
v0.3.4 fixes a bug v0.3.3's rewrite introduced: RawGps.Monitor
seeded its very first reading from LocationManager.getLastKnownLocation()
— Android's system-wide location cache, which can be hours or days old,
from wherever the device last got a fix. That made the map's accuracy
indicator show full bars indoors with zero satellites actually in fix
(and the puck could appear at a stale, unrelated position). A real GPS
unit shows "acquiring" until it actually has signal, never a leftover
reading from last time — so that seed is gone. Every consumer (map,
track recording, Developer options) now starts from its own "no fix yet"
state and stays there until a genuine live fix arrives. This also fixes
a latent correctness bug in track recording, where a stale cached point
could have been spliced into the start of a route.
v0.3.3 fixes a real accuracy gap: on the map, GPS accuracy floated at
±17-40m and struggled to settle, while Developer options (a separate code
path) converged to ±3-10m within seconds. Decompiling MapLibre's own
LocationComponent found the cause — despite the name, its default
"MapLibreFused" engine is not Google's fused provider; it's raw
android.location.LocationManager, and for high-accuracy requests it
registers both GPS_PROVIDER and the coarse NETWORK_PROVIDER
simultaneously with no preference for the better source, so a stale/weak
WiFi-based fix could just as easily land on the puck as a good GPS one.
Rather than pick a different arbitration scheme, every GPS source in the
app — the map puck, track recording, one-shot "mark here" capture, and
Developer options — now runs through one shared, pure-GNSS pipeline
(location/RawGps.kt) built directly on LocationManager.GPS_PROVIDER.
No Play Services, no network-based positioning anywhere: the same
category of source a dedicated handheld GPS unit uses, appropriate for
an app that has to work flawlessly with zero connectivity. The
play-services-location dependency and the ACCESS_COARSE_LOCATION
permission are gone entirely - nothing in the app requests network-based
location any more. The map's puck is fed via MapLibre's
LocationComponent.forceLocationUpdate() with useDefaultLocationEngine (false), so MapLibre no longer sources its own location at all.
v0.3.2 fixes the map's GPS signal indicator, which never lit up: it
read satellite status from a separate raw-GNSS registration that could
end up with no events at all if the fix was actually coming from a
non-GPS source (network/wifi) rather than the GNSS chip. It now reads the
same Location.accuracy (in metres) that already drives the blue dot's
own accuracy circle, so the indicator can't disagree with what's on the
map and needs no separate subscription. Full bars = accuracy ≤10m; each
bar down doubles the radius (≤20 / ≤40 / ≤80m) — accuracy degrades
exponentially with weaker signal, so a linear scale would peg almost
every fix at full bars and never show what actually matters. The exact
figure (e.g. "±14m") is printed under the bars.
v0.3.1 corrects a misread of v0.3.0's Settings ask:
- Settings → Structure types (not "Manage structures", which just
duplicated the per-project instance editing below). This is the
editable taxonomy — culvert, bridge, boardwalk, and so on — the
picklist shown when adding/editing a structure. Add, rename or delete
a type;
"other"is a permanent fallback (locked, can't be renamed or deleted) that a deleted type's structures fall back to instead of being left with a dangling value. First schema migration since P5 (structure_types, DB v2→v3, tested the same way as the v1→v2 one). - Settings → Developer options: live GPS fix (lat/lon, accuracy, altitude, speed, bearing, provider) plus per-satellite constellation, signal (CN0) and used-in-fix status, sorted strongest-first — for digging into poor-connection field reports.
v0.3.0 — field-test fixes:
- Location accuracy. Every one-shot "where am I" call (marking a task,
a structure, a point) used the fused provider's cached
lastLocation, which can be minutes old and/or a coarse network fix — that's what made on-the-ground marking land tens of metres off. They now force a freshPRIORITY_HIGH_ACCURACYGPS fix (location/CurrentLocation.kt). The map's live "blue dot" had the same problem with MapLibre's default engine request; it's now pinned to high accuracy too. - Map controls. A locate button on the map (bottom-right) cycles free pan → centre on me (north-up) → centre + rotate to your direction of travel. A Nokia-bars-style GPS signal indicator (top-right) — see v0.3.2 above for how its bars are actually derived now.
- Hold to edit. Structures and inspections now use the same "hold a card for 1 second" gesture as project cards, to rename/retype/delete. (v0.3.0 also put an instance list in Settings under "Manage structures" — corrected in v0.3.1 above, since editing individual structures already happens right here on the Structures tab.)
- Inspection dates are now
dd.MM.yyyy, HH:mminstead of a raw ISO timestamp.
v0.2.3 fixed the launcher icon: v0.2.2 shipped it over-cropped and
scaled ~1.8x too large (the generator discarded the source art's own
padding and re-scaled to a fixed 62% fraction instead of respecting the
template-calibrated scale already in appicon.png), so it looked
zoomed-in and clipped by circular/squircle launcher masks. Fixed by
resizing the source canvas as-is — see branding/README.md.
v0.2.1 fixed a crash:
a project whose only geo-tagged item was a single task (e.g. right after
adding its first task) threw InvalidLatLngBoundsException on every
attempt to open it, since MapLibre's LatLngBounds.Builder refuses fewer
than 2 points; MapGeo now builds a degenerate single-point bounds by hand
instead. GPX import is now reachable directly from the Route and Trails
tabs (scoped to the open project), not just Settings.
All of P0, P1, P4 and P5 are done; P2 and P3 are substantially done.
| Phase | State |
|---|---|
| P0 — fork & strip | ✅ repo, package rename, build, CI (android.yml + release.yml) |
| P1 — de-server the data layer | ✅ auth + network + sync removed; Room is the source of truth; on-device length/area/nearest-trail/rollups; opens straight to the project list |
| P2 — offline maps | ◐ Settings → Offline maps: a quick list (Rīga & Vidzeme / Latvia / Estonia / Lithuania) plus "Browse all countries" — every country in the world, grouped by continent, searchable. Any of them downloads to phone storage via MapLibre's offline store (tiles + style + fonts + sprite — a downloaded region is fully self-contained), with progress, delete, storage readout. A genuinely first-run device (never online, nothing downloaded) now gets a clear "connect once and download a region" message over a faint world-coastline backdrop, instead of a blank rectangle. A self-hosted pmtiles:// file (needs an external Planetiler run) is the one item still open — see tiles/README.md. |
| P3 — settings: username, DB cleanup, backup/restore | ◐ Settings: optional display name, "Clear photos of completed tasks", storage breakdown, full-workspace backup/restore zip (data + GeoJSON + photos; merge or replace). GPX import/export still to come. |
| P4 — off-grid field features | ✅ local reminders (inspection-due / task-overdue → notification inbox) · multi-format coordinates (decimal / DMS / UTM, copy-to-clipboard) · track-back — live distance+bearing to a route's start while recording, and a one-shot "bearing to start" check on any saved route. |
| P5 — trail condition reporting | ✅ trail_reports entity (status / kind / severity / note / GPS / photos), logged from the Route tab, shown as a coloured map layer with its own tap-to-open detail sheet, resolve/reopen/delete, a matching "clear resolved report photos" cleanup action. First Room migration (v1→v2), with a real plain-JVM test harness (app/src/test/) — no Robolectric, runs in CI. |
Bumped to MapLibre 11.8 (has pmtiles:// support for the next map step).
The map tab still points at an online style, but any region you download in
Settings is then served from local storage with no network.
On the project list, hold a project card for 1 second to rename it, change its activity, or delete it (two-step confirm; trails, structures and trail reports are shared and stay even if the project is deleted). The same hold-for-1s gesture edits a structure (Structures tab, or Settings → Manage structures) or an inspection (inside a structure's detail screen).
| Path | What |
|---|---|
android/ |
the app — Kotlin / Jetpack Compose, minSdk 26 / targetSdk 36 |
android/app/schemas/ |
exported Room schema (committed; version bumps need a migration) |
tiles/ |
Planetiler recipe + offline-region manifest |
docs/ |
the development plan |
cd android
./gradlew :app:assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
./gradlew :app:assembleRelease # debug-signed unless keystore.properties is presentJDK 17+ (CI uses Temurin 21). Android SDK via android/local.properties
(sdk.dir=...).
release.yml writes android/keystore.properties from repo secrets
(RELEASE_KEYSTORE_B64, RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS,
RELEASE_KEY_PASSWORD). Without them the release APK is debug-signed but
still installs. Locally, drop a keystore.properties next to
android/settings.gradle.kts:
storeFile=/path/to/trailkeeper-offgrid.jks
storePassword=…
keyAlias=…
keyPassword=…
Submission MR: https://gitlab.com/fdroid/fdroiddata/-/merge_requests/49328.
Release APKs are attached to GitHub Releases only, never committed into the
repo — F-Droid builds the APK itself from a tagged commit, and a source
repo with binaries baked into its history is exactly what that process
disallows. Each release is tagged (e.g. v0.3.9) to give a stable
inclusion target; release-tag.yml fires on that tag push and publishes a
permanent, never-overwritten GitHub Release at the same tag (distinct
from release.yml's rolling latest release, which gets replaced on every
push to main). .fdroid.yml's Binaries + AllowedAPKSigningKeys point
at that per-tag release so F-Droid's reproducible-build check can diff its
from-source build against the actual developer-signed APK.
The build recipe lives in this repo as .fdroid.yml; the
fdroiddata submission (metadata/com.trailkeeperoffgrid.app.yml there)
duplicates it rather than pointing at it, since that's what reviewers
actually want to see for a new-app MR. Keep the two in sync by hand on
every release. No non-free dependencies, trackers, or ad SDKs anywhere in
the tree; see metadata/en-US/full_description.txt.
The release signing key (keystore.properties / the .jks it points
at, held only as the RELEASE_KEYSTORE_B64 GitHub secret) has no backup
copy documented anywhere in this repo or its docs as of 2026-09-18. Losing
it means every future release stops being installable as an update over
existing installs, and breaks AllowedAPKSigningKeys verification. Back
it up somewhere durable before doing anything else with signing.