mirror of
https://github.com/opengeos/GeoLibre.git
synced 2026-11-04 10:43:36 +00:00
* 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>
344 lines
16 KiB
Markdown
344 lines
16 KiB
Markdown
# 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](https://play.google.com/store/apps/details?id=org.geolibre.app).
|
||
The Play build is signed and updates automatically:
|
||
|
||
[Get GeoLibre on Google Play](https://play.google.com/store/apps/details?id=org.geolibre.app){ .md-button .md-button--primary }
|
||
|
||
Prefer to sideload? Every [release](https://github.com/opengeos/GeoLibre/releases)
|
||
attaches signed, per-ABI APKs — see [Downloads](downloads.md#android-installation).
|
||
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`.
|
||
|
||
```bash
|
||
# 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 `export`s 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`):
|
||
|
||
```bash
|
||
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 `RUSTFLAGS` **environment variable**. Putting the same flags
|
||
> in `target.<triple>.rustflags` in a `.cargo/config.toml` does _not_ work: the
|
||
> Tauri CLI sets `RUSTFLAGS` itself when it invokes cargo for Android, and an
|
||
> env `RUSTFLAGS` overrides 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:
|
||
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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/android` is generated (git-ignored) and regenerated on demand. After
|
||
`init`, copy in the GeoLibre launcher icons and `src-tauri/android/MainActivity.kt`
|
||
(as above; CI does the same). The template's `MainActivity` draws edge-to-edge
|
||
without insetting the content, so on WebViews that report
|
||
`env(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 `.so` with
|
||
debuginfo).
|
||
- `--split-per-abi` emits one APK per architecture instead of a single ~150 MB
|
||
universal APK. Install the **`arm64`** one 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 under `lib/`.
|
||
|
||
- 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-abi` the 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):
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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.jks`
|
||
- `ANDROID_KEYSTORE_PASSWORD`
|
||
- `ANDROID_KEY_ALIAS`
|
||
- `ANDROID_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 `.aab` is the Google Play upload format
|
||
> and cannot be installed on a device.
|
||
|
||
## Install / test
|
||
|
||
### On a phone
|
||
|
||
1. Enable **Developer options** (tap Build number 7×) and **USB debugging**.
|
||
2. Sideload the signed APK:
|
||
```bash
|
||
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
|
||
|
||
```bash
|
||
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`](https://play.google.com/store/apps/details?id=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.
|
||
|
||
1. **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.
|
||
2. **Play App Signing.** Upload `upload.jks` as the _upload_ key; Google holds
|
||
the actual app signing key and re-signs each bundle. The repository's
|
||
`ANDROID_KEYSTORE_*` secrets are that upload key — keep the keystore backed
|
||
up, since losing it requires a Play support reset.
|
||
3. **Upload the AAB** from the `geolibre-android-play-aab` CI artifact, or from
|
||
the release's `geolibre-android.aab` asset once that artifact has expired. The
|
||
`versionCode` is derived from the version in `tauri.conf.json` and must
|
||
increase on every upload.
|
||
4. **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.
|
||
5. **Privacy policy URL** — point at the published [privacy policy](privacy.md).
|
||
6. **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.
|
||
7. **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.006`
|
||
- `geo:40.7128,-74.006?z=12`
|
||
- `geo: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:
|
||
|
||
```bash
|
||
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.
|