* feat(postgis): render PostGIS vector tiles natively on Android/iOS The mobile apps cannot run the Martin tile server, so Add Data > PostgreSQL was hidden there (GeoLibre#2091). Render the tiles in-process instead: the Tauri backend connects to PostgreSQL directly (tokio-postgres + deadpool, rustls/ring) and builds each tile with ST_AsMVT, and MapLibre reaches it through a geolibre-pgtiles:// protocol. No process is spawned and no port is bound. - Tile URLs persist only the password-masked connection label; the protocol resolves it from this session's connects or the saved connections, so a reopened project's layers work without credentials in the project file. - The tile envelope is clipped to the table's extent before it is transformed, so projected CRSs (e.g. UTM) still render at low zoom. - SRID-0 columns use the rows' own SRID when they carry one, else the default SRID field. - sslmode follows libpq: prefer/require encrypt without verifying, and verify-ca/verify-full check against the webpki roots. - Mobile Tauri gets a tiles-only PostgreSQL form; desktop keeps Martin and the editable mode unchanged, and mobile browsers still hide the source. Tested on an Android phone (Redmi Note 9 Pro) against PostGIS 3.6.1 with EPSG:4326, 32748 and 54004 tables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(postgis): address review findings on native PostGIS tiles Backend (postgis_tiles.rs): - Connect outside the global pool lock: one OnceCell slot per connection, so a slow host no longer stalls tiles from other databases and parallel first tiles share one connect. - Key pools by connection only; the default SRID moves into the plan key, so layers with different default SRIDs stop closing each other's pool. - SET statement_timeout in a post-create hook instead of the `options` startup parameter, which PgBouncer and hosted poolers reject. - Build each tile plan once per key (no stampede on first draw); a plan built without its extent clip is retried after 60 s, not cached forever. - source_info reuses the plan, and a failed extent yields bounds: None instead of failing the add. - Evict the least recently used pool, not the oldest. - Parse sslmode/sslrootcert like libpq (URI and quoted key/value forms), support sslrootcert PEM files, and implement verify-ca as chain-only. Frontend: - Mask password/sslpassword query parameters in connection labels so they never reach persisted tile URLs. - Queue tile requests per connection and drop ones MapLibre aborts. - Close a database's pool when its last layer is removed or the Add Data form closes unused; a replaced session connection is disconnected. - Note the certificate behaviour in the mobile notice and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(postgis): address code review of native PostGIS tiles Backend: - Evict only after a new connection succeeds, so a mistyped string never costs a pool that live layers use; slots still connecting don't count. - Never close a pool explicitly: eviction and disconnect drop the map's reference, and connections close when the last in-flight request ends. That stops in-flight tiles failing and covers a disconnect mid-connect. - One PostgisTileSourceRequest replaces the five loose command arguments and the repeated SourceKey construction; use f64::clamp. Frontend: - The Add Data form releases every pool it opened and no layer uses when it moves to another database or closes, not just the last one. - Shared helpers: savedPostgresConnectionsWithLabel, the POSTGIS_TILES_SOURCE_KIND constant, and a PostgresConnectionFields component used by both the Martin and the mobile forms. - MAX_TILES_IN_FLIGHT moves next to the URL helpers, and a test keeps it equal to the Rust POOL_SIZE (documented in docs/maintenance.md). i18n: - The reworded web notice gets a new key (webUnavailableNotice); the stale addData.postgres.desktopOnlyNotice is removed from every catalog so non-English users fall back to the correct English text. - New keys for the read-only table picker (tileSourceTable) and the unresolved-connection tile error, now thrown through i18next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(postgis): address second CodeRabbit review of native PostGIS tiles - Keep serving an unclipped tile plan while one background task retries its extent, and double the retry wait after each failure (up to 1 h), so a slow or failing extent scan no longer stalls the layer's tiles. - Parse geolibre-pgtiles:// tile URLs by hand: Chromium and Android WebView before 130 read non-special schemes as an opaque path with no host, so every tile failed to parse there. - Mask connection labels with the regex path when the URL parser finds no host, so those WebViews no longer leave the userinfo password in the persisted label. - Docs: sslmode=prefer can fall back to plaintext; require, verify-ca or verify-full make encryption mandatory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(postgis): single plan rebuild guard and bounded password mask - PlanRetry tracks an in-flight rebuild, released by a drop guard however the task ends, so a rebuild that outlasts its retry delay can no longer be joined by a second one. - The userinfo password regex stops at `/`, `?` and `#`, so a port followed by an `@` in the query (`host:5432/db?application_name=a@b`) is no longer masked as a password and the label keeps its port and database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
16 KiB
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).
Install
GeoLibre is published on Google Play. The Play build is signed and updates automatically:
Get GeoLibre on Google Play{ .md-button .md-button--primary }
Prefer to sideload? Every release attaches signed, per-ABI APKs — see Downloads. The rest of this page is for developers building the app themselves.
What works on Android vs desktop
The Android build ships the full map workspace, Add Data, the Whitebox geoprocessing toolbox (1,000+ tools, run in the browser on WebAssembly), 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 → GeoLibre Toolbox → Conversion, AI Segmentation, and the sidecar-backed Raster tools (all need the Python sidecar; the Raster submenu keeps only the client-side Download Global DEM tool)
- Add Data → SQL Server
These are gated by a user-agent isMobile() check, so the Processing menu, the
Add Data menu, and the Layer panel's add-data group never offer them. Everything
else runs client-side.
Add Data → PostgreSQL works differently on mobile. The app cannot launch
the Martin tile server, so the database renders the vector tiles itself
(ST_AsMVT, PostGIS 3.0 or later) and the app fetches them over a direct
PostgreSQL connection. The database server must therefore be reachable from
the device's network. Mobile layers are read-only: the editable mode needs the
desktop app's Python sidecar.
TLS follows libpq's sslmode. prefer (the default) tries TLS first but
falls back to a plaintext connection when the server does not offer it; to
make encryption mandatory, use require, verify-ca or verify-full.
require encrypts without checking the server certificate, verify-ca
checks the chain and verify-full also the host name, against the bundled
public roots or the PEM file named by sslrootcert. Connections through PgBouncer and hosted poolers
work: the 30-second query timeout is set after connecting, not as a startup
parameter.
The database sources have one entry point that check does not cover: the Browser
panel keeps its Databases section on every platform for discovery, so its +
still opens the connection dialog. The SQL Server dialog gates on
isDesktopRuntime() (isTauri() && !isMobile()) rather than on isTauri()
alone, so on Android it shows the "requires GeoLibre Desktop" notice and
disables Connect instead of calling a helper that cannot exist (GeoLibre#2091).
The PostgreSQL dialog opens its mobile form instead. Expanding a saved
PostgreSQL connection in the Browser panel to list its tables still needs the
desktop app.
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-36" \
"build-tools;36.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.
API 36 (Android 16), not 34: the Tauri v2.11 Android template generates
compileSdk = 36 / targetSdk = 36, so Gradle needs the matching platform
installed. It is also Google Play's floor — from 2026-08-31 new apps and
updates must target API 36 to be accepted.
16 KB page sizes
Google Play rejects apps targeting Android 15+ whose native libraries are not
aligned for 16 KB memory pages, and such libraries fail to load on 16 KB
devices. NDK r28+ does this by default; r27 does not, so the flags are
passed explicitly. Export this before an Android build (CI sets it at workflow
level in .github/workflows/android.yml):
export RUSTFLAGS="-C link-arg=-Wl,-z,max-page-size=16384 -C link-arg=-Wl,-z,common-page-size=16384"
It has to be the
RUSTFLAGSenvironment variable. Putting the same flags intarget.<triple>.rustflagsin a.cargo/config.tomldoes not work: the Tauri CLI setsRUSTFLAGSitself when it invokes cargo for Android, and an envRUSTFLAGSoverrides the config file outright. The config-file form is silently ignored — it parses, it builds, and it ships 4 KB-aligned libraries that Play rejects. Tauri appends to an inherited value, so exporting it works.
Check the result on a built APK — the bytes Play actually receives:
unzip -o -q app-arm64-release-unsigned.apk 'lib/*/*.so' -d /tmp/apkcheck
"$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objdump" -p \
/tmp/apkcheck/lib/*/*.so | awk '$1 == "LOAD" { print $NF }' | sort -u
# every value must be 2**14 or greater; 2**12 means the flags did not apply
Use llvm-objdump -p rather than readelf -l: readelf wraps each LOAD across
two lines, so it is easy to parse the wrong column and read an address as an
alignment. CI runs this same check over every packaged .so and fails on a
regression.
Build
cd apps/geolibre-desktop
npx tauri android init # generate src-tauri/gen/android (once)
cp -r src-tauri/icons/android/. src-tauri/gen/android/app/src/main/res/
cp src-tauri/android/MainActivity.kt \
src-tauri/gen/android/app/src/main/java/org/geolibre/app/MainActivity.kt
npx tauri android build --apk --split-per-abi # release APKs, one per ABI (~40 MB each)
npx tauri android build --aab # universal AAB for Google Play
-
gen/androidis generated (git-ignored) and regenerated on demand. Afterinit, copy in the GeoLibre launcher icons andsrc-tauri/android/MainActivity.kt(as above; CI does the same). The template'sMainActivitydraws edge-to-edge without insetting the content, so on WebViews that reportenv(safe-area-inset-*)as 0 the toolbar ends up under the status bar and stops taking taps (GeoLibre#3044). -
Build release, not
--debug: the stripped, size-optimized Cargo profile makes each APK ~40 MB; a debug build is ~200 MB (unstripped.sowith debuginfo). -
--split-per-abiemits one APK per architecture instead of a single ~150 MB universal APK. Install thearm64one on real phones. -
Output:
src-tauri/gen/android/app/build/outputs/apk/<abi>/release/app-<abi>-release-unsigned.apk, where<abi>is Tauri's short name —arm64,arm,x86,x86_64— not the Android ABI directory name (arm64-v8a,armeabi-v7a) that appears inside the APK underlib/. -
Sideload/GitHub-release path: the per-ABI APKs.
-
Google Play path: the universal AAB (Play generates per-device splits from it). Don't
--split-per-abithe AAB — Play wants the one bundle.
The app is named GeoLibre on Android (the desktop build is "GeoLibre
Desktop") and uses the package id org.geolibre.app, both set via
src-tauri/tauri.android.conf.json, which also drops the Python backend from
the Android bundle.
The Android id is overridden there rather than in tauri.conf.json on purpose:
identifier is shared by every platform and also determines the macOS bundle ID,
the Linux AppStream id, and the webview data directory. Changing it globally
would orphan existing desktop users' settings and break the Linux/COPR/Homebrew
packaging, all of which still key off org.geolibre.desktop.
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/36.0.0"
KS="$HOME/.android/debug.keystore" # auto-created by Android tooling; or make your own
# -P 16, not -p: -p only guarantees 4 KB, and Play requires the .so to sit on a
# 16 KB boundary inside the zip. Same flag CI uses.
"$BT/zipalign" -P 16 -f 4 app-arm64-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
The example signs the arm64 APK. Substitute the ABI you need throughout —
app-x86_64-release-unsigned.apk → geolibre-x86_64.apk, and likewise for
armeabi-v7a / x86. The emulator section below installs geolibre-x86_64.apk,
which is this same walkthrough with arm64 swapped for x86_64.
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. On a published
release the APKs are also attached to that release as downloadable assets —
but only when they carry a real release signature; debug-signed APKs stay a CI
artifact and the run logs a warning saying so. 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.jksANDROID_KEYSTORE_PASSWORDANDROID_KEY_ALIASANDROID_KEY_PASSWORD
It also builds a universal AAB and uploads it as the separate
geolibre-android-play-aab artifact — but only on runs that have the real
release keystore, since Play rejects a debug-signed bundle. Without the keystore
the AAB build is skipped entirely rather than built and discarded. On a published
release the geolibre-android.aab is attached to the release alongside the APKs,
so the exact bundle submitted for a tag stays recoverable after the CI artifact
expires.
Sideloading? Take an
.apk. The.aabis the Google Play upload format and cannot be installed on a device.
Install / test
On a phone
- Enable Developer options (tap Build number 7×) and USB debugging.
- Sideload the signed APK:
Or copy the APK to the phone and tap it (allow "install unknown apps").
adb install -r geolibre-arm64.apk
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-36;google_apis_playstore;x86_64"
avdmanager create avd -n geolibre \
-k "system-images;android-36;google_apis_playstore;x86_64" -d pixel_7
emulator -avd geolibre
# x86_64 APK to match the x86_64 system image — the emulator can translate the
# arm64 build, but far slower, and it would not exercise the x86_64 libraries.
adb install -r geolibre-x86_64.apk
If you ever rebuild with a different signing key, uninstall the old copy first (
adb uninstall org.geolibre.app) — Android rejects updates whose signature changed. This also applies when moving between a sideloaded APK and the Play build: Play App Signing re-signs with Google's key, so the two are not upgrade-compatible.
Publishing to Google Play
GeoLibre is live on Play as
org.geolibre.app;
this section records the onboarding for reference and for anyone publishing a
fork. Only step 3 recurs per release: build the AAB with the upload key and bump
the versionCode. The build side is covered by the CI workflow above; the rest
is one-time Play Console onboarding.
- Developer account ($25, one-time). Register as an organization rather than a personal account if you can: personal accounts created after 2023-11-13 must run a closed test with 12 opted-in testers for 14 consecutive days before they can apply for production access. Organization accounts are exempt.
- Play App Signing. Upload
upload.jksas the upload key; Google holds the actual app signing key and re-signs each bundle. The repository'sANDROID_KEYSTORE_*secrets are that upload key — keep the keystore backed up, since losing it requires a Play support reset. - Upload the AAB from the
geolibre-android-play-aabCI artifact, or from the release'sgeolibre-android.aabasset once that artifact has expired. TheversionCodeis derived from the version intauri.conf.jsonand must increase on every upload. - Store listing assets: 512×512 icon, a 1024×500 feature graphic, and at least two phone screenshots. Add 7-inch and 10-inch tablet screenshots too — Play down-ranks apps without them, and a GIS workspace is genuinely tablet-appropriate.
- Privacy policy URL — point at the published privacy policy.
- Data safety form. Declare each network destination honestly: geocoding, the AI assistant, basemap/tile fetches, and Google OAuth for Earth Engine. Note which are transmitted versus collected — GeoLibre does not operate a backend that retains user data, but the form asks per-purpose.
- Content rating questionnaire and target audience.
Keep Known limitations below in mind for each update: several Add Data paths
are still inert on Android. A user tapping one and getting nothing is a one-star
review, so gate them on mobile the way the sidecar tools already are via
isMobile().
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.
Open a location from another app
GeoLibre handles Android ACTION_VIEW intents with the geo: scheme, both
when launching the app and while it is already open. Supported forms include:
geo:40.7128,-74.006geo:40.7128,-74.006?z=12geo:0,0?q=40.7128,-74.006(New%20York)&z=12
Coordinates use latitude, longitude order. Numeric q coordinates override
the URI's placeholder coordinates; address-only queries are not supported.
An optional third coordinate in a direct URI is altitude in meters; it is validated
and ignored when positioning the map. Zoom defaults to 14 and must be between
0 and 24. Invalid locations are ignored.
A received location moves the map without replacing the current project's layers.
The Tauri deep-link plugin generates the Android intent filter from
tauri.android.conf.json, so it also applies when CI regenerates gen/android.
To check both a cold launch and a running app on a connected device, run this
command twice, panning the map between invocations:
adb shell am start -a android.intent.action.VIEW -d 'geo:0,0?q=40.7128,-74.006&z=12' org.geolibre.app
Web links can open the same location with
https://geolibre.app/?lat=40.7128&lon=-74.006&zoom=12 or the compact form
https://geolibre.app/?12/40.7128/-74.006. A coordinate-only launch takes
precedence over saved startup settings and skips onboarding. An explicit
project or data link retains its existing camera/loading behavior when combined
with coordinate parameters.