Files
GeoLibre/docs/android.md
T
Qiusheng Wu 04f581cc75 feat(android,pwa): native Android build + offline caching + region download (#377)
* feat(android,pwa): native Android build + offline caching + region download

Android (Tauri v2 mobile):
- Make the crate compile for Android: cfg-gate the desktop-only OAuth-popup
  window code (WebviewWindowBuilder::{on_new_window, window_features} don't
  exist on the mobile runtime) behind #[cfg(desktop)]. Desktop behavior is
  unchanged; a debug APK now builds via `tauri android build`.
- Add .github/workflows/android.yml to build the debug APK in CI (JDK 21,
  Android SDK + NDK r27 LTS, Rust android targets) and upload it as an artifact.
- Document the port status, verified toolchain, and remaining work in
  docs/android-port-spike.md.

PWA / offline:
- Cache the CDN-loaded heavy engines (Pyodide, PGlite/PostGIS) in a new
  geolibre-cdn-engines CacheFirst rule so browser SQL + Python work offline
  after first use; raise the basemap cache cap for region downloads.
- Add a "Download Offline Area" feature (Project menu): pick the current view +
  zoom range and pre-fetch basemap tiles/style/sprite/glyphs into the SW cache.
  New lib/offline-tiles.ts (tile math + cache warming) with unit tests, and
  OfflineRegionDialog.

i18n: add the offline.* and toolbar.item.offlineRegionEllipsis strings (en).

* feat(mobile): responsive panel overlays + touch-capable resizing

- On phone-width viewports (max-md), expanded Layers/Style panels now overlay
  the map as slide-over sheets (absolute, z-30) instead of squeezing it into a
  sliver; collapsed thin bars stay in-flow. The map stays full-bleed and
  primary. Verified at 390x844 (map 640px tall; expanded panel overlays).
- Make panel resize handles touch-capable: convert the layer/style resize
  handlers and handle elements from mouse events to Pointer Events (+touch-none),
  so resizing works with touch/stylus, not just a mouse. Desktop is unchanged
  (pointer events also fire for mouse).
- Extract the duplicated inline isMobileViewport() checks into a shared, reactive
  useIsMobileViewport hook (with a getIsMobileViewport one-shot for initial
  state); LayerPanel/StylePanel now import it.

Also removes docs/android-port-spike.md.

* feat(mobile): hide sidecar/desktop-only tools on mobile (isMobile gating)

isTauri() is true on Android, so sidecar-backed tools previously appeared in the
UI and failed at runtime. Add a UA-based isMobile() helper (distinct from the
narrow-viewport hook) and hide the tools that can't run on a mobile OS:

- Processing menu: Whitebox, format Conversion, Raster tools, and AI Segmentation
  (all require the Python sidecar) are hidden on mobile. Vector (Turf), SQL
  (PGlite/DuckDB), Python (Pyodide), geocode, statistics, network, and the
  assistant run client-side and remain.
- Add Data menu: the PostgreSQL source (served via the Martin binary, no Android
  build) is hidden on mobile.

Verified in a real browser: with an Android UA those entries are absent while the
client-side tools remain; desktop is unchanged. Adds tests/is-mobile.test.ts.

* build(android): ship slim release APKs + sign in CI

The debug APK was ~208 MB, dominated by the unstripped debug Rust .so (210 MB).
Switch the Android workflow to a release build, which uses the existing
size-optimized + stripped Cargo profile → ~41 MB APK (.so ~38 MB), a ~5x cut.

- .github/workflows/android.yml: build release (not --debug), then zipalign +
  sign. Uses release-keystore secrets (ANDROID_KEYSTORE_BASE64 / _PASSWORD /
  ANDROID_KEY_ALIAS / _PASSWORD) when present; otherwise falls back to a
  throwaway debug keystore so the CI artifact is still installable for testing.
- tauri.android.conf.json: drop bundle.resources (the Python backend) on Android
  — it can't run there and only bloats the package.

* fix(android): safe-area insets, launcher icon, per-ABI APK splits

Three issues found testing the APK on a phone:

- Status bar overlap / unclickable menus: the webview drew edge-to-edge, so the
  top toolbar rendered under the Android status bar — which intercepts taps,
  making those menu buttons unresponsive. Add viewport-fit=cover and pad #root by
  env(safe-area-inset-*) so the chrome clears the system bars and cutouts. Insets
  are 0 on desktop/web, so it only affects mobile. Verified on an emulator.

- Wrong launcher icon: `tauri android init` writes default Tauri icons. CI now
  copies the checked-in GeoLibre icons (src-tauri/icons/android) over the
  generated mipmaps after init. Verified the GeoLibre globe shows in the launcher.

- Oversized CI APK (~150 MB): the workflow built a universal APK bundling all
  four ABIs. Build --split-per-abi instead and sign each, giving ~40 MB
  per-architecture APKs (install the arm64-v8a one on real phones).

* feat(android): name the app "GeoLibre" (not "GeoLibre Desktop")

Override productName to "GeoLibre" in tauri.android.conf.json so the Android
launcher label and activity title read "GeoLibre". The desktop build keeps
"GeoLibre Desktop" (base tauri.conf.json). Verified a clean `tauri android init`
generates app_name=GeoLibre in strings.xml.

* fix(android): pad adaptive launcher icon into the safe zone

The foreground filled the full 108dp canvas, so the launcher's adaptive mask
cropped the globe's edges. Scale the foreground to ~64% centered (all densities)
so the whole icon stays within the ~66dp safe zone and is never clipped.

* docs: document Android app, mobile, and offline features for v1.3

- README: native Android app + Download Offline Area + engine offline caching in
  the v1.3 feature list, an Android build/run section, and a Reference link.
- New docs/android.md: toolchain setup, build, signing, sideload, and emulator
  guide (added to mkdocs nav).
- roadmap: a "Mobile, offline, and Android" subsection under v1.3.
- index: mention Android/mobile/offline in the v1.3 status summary.

Also: build the Android APK only on published releases (and on demand), not on
every PR — the Rust cross-compile + Gradle build is slow.

* fix(ui): keep long dropdown menus within the visible viewport on mobile

On Android the dropdown max-height used 100vh / Radix's available-height, both
based on the layout viewport, which extends under the system status/navigation
bars. Long menus (e.g. Add Data) then ran their last items under the nav bar
where they were clipped and unreachable. Cap the max-height with dvh minus the
top/bottom safe-area insets so the menu (and its scroll area) stays within the
visible region and scrolls to the end. Desktop/web are unchanged (dvh == vh and
insets are 0).

* Address Copilot/Claude/CodeRabbit review feedback

offline-tiles.ts:
- collectOfflineUrls: accept an AbortSignal (Cancel now interrupts TileJSON
  discovery), thread source `subdomains` into expandTileUrl, and handle the
  MapLibre array `sprite` format so multi-sprite styles cache their icons.
- expandTileUrl: drop `{s}` together with its trailing dot when no subdomains,
  avoiding invalid `https://.host/...` URLs.
- Glyphs: stop encodeURIComponent-ing the fontstack so the warmed URL matches
  MapLibre's actual (browser-normalized) glyph request and the SW cache key hits.
- countTiles/enumerateTiles: split antimeridian-crossing bboxes (west > east).
- warmUrls: clamp concurrency to >=1, and don't increment `done` on aborted
  fetches (finally ran after the catch `return`).

OfflineRegionDialog.tsx: catch collectOfflineUrls errors (no false "done"),
abort the in-flight download when the dialog closes, only clear abortRef if it
still points to this run, disable Download when no service worker is active, and
warn when the area exceeds the SW cache cap.

DesktopShell.tsx: panel resize handles now setPointerCapture, listen for
pointercancel, and tear down on unmount, so a canceled/interrupted drag can't
leave listeners attached or document.body styles stuck.

is-mobile.ts: detect iPadOS 13+ desktop-UA via maxTouchPoints (+ tests).

AddDataMenu/ProcessingMenu: memoize the isMobile() check (stable per session).

android.yml: pin android-actions/setup-android to a commit SHA, rename the job
to "(release)", fail fast when keystore secrets are partially set, and pass
apksigner passwords via files instead of CLI args.
2026-06-15 01:06:18 -04:00

5.7 KiB
Raw Blame History

Android

GeoLibre runs as a native Android app built from the same React codebase via Tauri v2 mobile — no separate app. The webview UI is bundled in the APK, so the app shell works offline; map tiles and the heavier engines are fetched on demand (same as the desktop build).

What works on Android vs desktop

The Android build ships the full map workspace, Add Data, the Vector tools (Turf.js / in-browser GeoPandas via Pyodide), the SQL Workspace (DuckDB-WASM and the in-browser PGlite/PostGIS engine), the Python Console (Pyodide), geocoding, statistics, the AI assistant, story maps, and plugins.

Tools that depend on a local desktop process are hidden on mobile, because Android has no Python sidecar or local helper binaries:

  • Processing → Whitebox, Raster, Conversion, AI Segmentation (all need the Python sidecar)
  • Add Data → PostgreSQL (served by the local Martin tile server)

These are gated by a user-agent isMobile() check so they never appear and then fail. Everything else runs client-side.

Toolchain setup (one time)

You need the Android SDK + NDK, a JDK (17 or 21 — newer JDKs can break the Android Gradle Plugin), and the Rust Android targets. The cleanest, sudo-free layout keeps everything under a user-writable SDK at ~/Android/Sdk.

# 1. JDK 17/21 (or reuse Android Studio's bundled JBR at /opt/android-studio/jbr)
export JAVA_HOME=/path/to/jdk-21

# 2. Android SDK components (sdkmanager ships with Android Studio cmdline-tools)
export ANDROID_HOME="$HOME/Android/Sdk"
yes | sdkmanager --sdk_root="$ANDROID_HOME" --licenses
sdkmanager --sdk_root="$ANDROID_HOME" \
  "platform-tools" "platforms;android-34" \
  "build-tools;34.0.0" "ndk;27.3.13750724"
export NDK_HOME="$ANDROID_HOME/ndk/27.3.13750724"   # Tauri needs NDK_HOME

# 3. Rust + the four Android targets (install rustup if you don't have it)
rustup target add aarch64-linux-android armv7-linux-androideabi \
                  i686-linux-android x86_64-linux-android

NDK r27 (LTS) is the supported line for Tauri v2. Add the four exports to your shell profile so every session has them.

Build

cd apps/geolibre-desktop
npx tauri android init                          # generate src-tauri/gen/android (once)
npx tauri android build --apk --split-per-abi    # release APKs, one per ABI (~40 MB each)
  • gen/android is generated (git-ignored) and regenerated on demand.
  • Build release, not --debug: the stripped, size-optimized Cargo profile makes each APK ~40 MB; a debug build is ~200 MB (unstripped .so with debuginfo).
  • --split-per-abi emits one APK per architecture instead of a single ~150 MB universal APK. Install the arm64-v8a one on real phones.
  • Output: src-tauri/gen/android/app/build/outputs/apk/<abi>/release/app-<abi>-release-unsigned.apk.

The app is named GeoLibre on Android (the desktop build is "GeoLibre Desktop"), set via src-tauri/tauri.android.conf.json, which also drops the Python backend from the Android bundle.

Signing

Release APKs are unsigned. To install one, sign it (a debug key is fine for testing; use a real key for distribution):

BT="$ANDROID_HOME/build-tools/34.0.0"
KS="$HOME/.android/debug.keystore"   # auto-created by Android tooling; or make your own
"$BT/zipalign" -p -f 4 app-arm64-v8a-release-unsigned.apk aligned.apk
"$BT/apksigner" sign --ks "$KS" --ks-pass pass:android \
  --ks-key-alias androiddebugkey --key-pass pass:android \
  --out geolibre-arm64.apk aligned.apk
"$BT/apksigner" verify geolibre-arm64.apk

For a real upload/release key:

keytool -genkeypair -v -keystore upload.jks -alias upload -keyalg RSA \
  -keysize 2048 -validity 10000

Continuous integration

.github/workflows/android.yml builds signed, per-ABI release APKs on each published GitHub release (and on demand via the "Run workflow" button) and uploads them as the geolibre-android-release-apks artifact. It signs with your release keystore when these repository secrets are set, and otherwise falls back to a throwaway debug key so the artifact is still installable for testing:

  • ANDROID_KEYSTORE_BASE64 — base64 -w0 upload.jks
  • ANDROID_KEYSTORE_PASSWORD
  • ANDROID_KEY_ALIAS
  • ANDROID_KEY_PASSWORD

Install / test

On a phone

  1. Enable Developer options (tap Build number 7×) and USB debugging.
  2. Sideload the signed APK:
    adb install -r geolibre-arm64.apk
    
    Or copy the APK to the phone and tap it (allow "install unknown apps").

For live development with hot reload, connect the device and run npm run tauri android dev.

On an emulator

sdkmanager --sdk_root="$ANDROID_HOME" \
  "emulator" "system-images;android-34;google_apis_playstore;x86_64"
avdmanager create avd -n geolibre \
  -k "system-images;android-34;google_apis_playstore;x86_64" -d pixel_7
emulator -avd geolibre
adb install -r geolibre-arm64.apk

If you ever rebuild with a different signing key, uninstall the old copy first (adb uninstall org.geolibre.desktop) — Android rejects updates whose signature changed. The package id stays org.geolibre.desktop even though the visible name is "GeoLibre".

Known limitations / follow-ups

  • Local-file sources (MBTiles, local rasters, project files) assume real filesystem paths; Android scoped storage returns content URIs, so those flows need adapting before they work natively.
  • The Download Offline Area tool relies on a service worker, which the Tauri builds (desktop and Android) don't use — it's a PWA feature. Native offline basemap caching (bundled/downloaded MBTiles/PMTiles) is a future enhancement.
  • Earth Engine OAuth uses a desktop loopback/multi-window flow; a mobile deep-link redirect is future work.