Skip to content

Repository files navigation

Trailkeeper Offgrid

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.

License

GPL-3.0 — see LICENSE.

Install

No Play Store. Download the APK and open it (allow "install unknown apps"):

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).

Status

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 .tkshare bundle — a zip of JSON tables plus any referenced photos — via the new ShareBundle.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.java for a reified type parameter) that, on-device, handed Gson a type it resolved to raw LinkedTreeMap elements 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 use TypeToken.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 projectId on trails/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 predate projectId can 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 own photos/ folder (a classic "Zip Slip" path traversal), and a crafted photosJson.url could 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.yml and 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 fresh PRIORITY_HIGH_ACCURACY GPS 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:mm instead 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).

Layout

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

Build

cd android
./gradlew :app:assembleDebug        # -> app/build/outputs/apk/debug/app-debug.apk
./gradlew :app:assembleRelease      # debug-signed unless keystore.properties is present

JDK 17+ (CI uses Temurin 21). Android SDK via android/local.properties (sdk.dir=...).

Release signing

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=…

F-Droid

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.

About

Single-user, fully offline Android field app for trail monitoring & maintenance — live GPS, offline vector maps, tasks with photos, route recording, structures & inspections, segment-timed work, and trail-condition reports. No backend, no account: all data stays on the phone; backup/restore and GPX are the only bridge out.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages