# 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..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//release/app--release-unsigned.apk`, where `` 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.