maps/docs/ARCHITECTURE.md
2026-03-30 09:22:16 +02:00

38 KiB

Architecture Document: Privacy-First Maps Application

Version: 1.0.0 Date: 2026-03-29 Status: Ready for development review


1. High-Level Architecture

                          +-----------------------+
                          |     Mobile App        |
                          |  (Flutter / Dart)     |
                          |                       |
                          |  +-------+ +--------+ |
                          |  |MapLibre| |Drift DB| |
                          |  |GL Native| (SQLite)| |
                          |  +-------+ +--------+ |
                          +-----------+-----------+
                                      |
                                TLS 1.2+ only
                                      |
                          +-----------v-----------+
                          |   Rust API Gateway    |
                          |   (Actix-web)         |
                          |                       |
                          |  /tiles/* /api/search  |
                          |  /api/route /api/pois  |
                          |  /api/offline /api/health
                          +-+------+------+------+-+
                            |      |      |      |
               +------------+  +---+--+ +-+----+ +----------+
               |               |      | |      |            |
        +------v------+ +-----v----+ +v------v-+  +--------v--------+
        |   Martin    | |  Photon  | |  OSRM    |  |  PostgreSQL     |
        | Tile Server | | Geocoder | | x3       |  |  + PostGIS      |
        |  (Rust)     | | (Java)   | | profiles |  |                 |
        +------+------+ +----------+ +----------+  +-----------------+
               |                                           ^
               +-------------------------------------------+
                         (reads tile data from PostGIS)

        +-------------------+
        |      Redis        |
        | (response cache)  |
        +-------------------+

        +-----------------------------+
        |  Caddy / nginx              |
        |  (TLS termination,          |
        |   reverse proxy)            |
        +-----------------------------+

Data flow summary:

  1. The mobile app connects exclusively to the Rust API Gateway over TLS.
  2. The gateway routes requests to the appropriate upstream service.
  3. Martin reads vector tile data from PostGIS.
  4. Photon provides geocoding from its own Elasticsearch-backed index (built from OSM/Nominatim).
  5. OSRM provides routing from preprocessed OSM graph data (3 separate instances).
  6. The gateway directly queries PostGIS for POI data.
  7. Redis caches frequently accessed responses (tiles, POI queries, region metadata).

2. Component Responsibilities

2.1 Mobile App (Flutter)

  • Renders vector map tiles via MapLibre GL Native.
  • Manages on-device state: search history, favorites, offline regions.
  • Handles all user interactions: gestures, search, routing, POI browsing.
  • Implements offline mode using locally stored MBTiles, OSRM data, and POI SQLite databases.
  • Enforces TLS-only communication; no third-party network calls.

2.2 Rust API Gateway (Actix-web)

  • Single entry point for all mobile app requests.
  • Proxies tile requests to Martin.
  • Proxies search/reverse-geocoding requests to Photon.
  • Proxies routing requests to the correct OSRM instance based on profile.
  • Serves POI data directly by querying PostGIS.
  • Serves offline data packages (tiles, routing data, POI extracts).
  • Performs input validation, rate limiting, CORS, and health checks.
  • Implements Redis caching layer.
  • Logs requests without PII (no IPs, no query strings, no coordinates).

2.3 Martin (Tile Server)

  • Serves Mapbox Vector Tiles (MVT) from PostGIS.
  • Serves the style.json used by MapLibre GL to configure rendering.
  • Connected directly to PostGIS; reads from openmaptiles-schema tables.

2.4 Photon (Geocoder)

  • Forward geocoding: text query to coordinates + metadata.
  • Reverse geocoding: coordinates to address/place.
  • Stateless: does not log queries.
  • Backed by Elasticsearch index built from Nominatim/OSM data.

2.5 OSRM (Routing Engine)

  • Three separate instances, one per profile: driving, walking, cycling.
  • Each uses a profile-specific graph preprocessed from OSM PBF data.
  • Returns routes with geometry, distance, duration, and turn-by-turn steps.
  • Supports alternative routes (up to 3).

2.6 PostgreSQL + PostGIS

  • Stores OSM-imported geographic data used by Martin for tile generation.
  • Stores POI data queried directly by the Rust gateway.
  • Spatial indexes enable efficient bounding-box and proximity queries.

2.7 Redis

  • Caches tile responses, POI bounding-box query results, and region metadata.
  • Reduces load on PostGIS and upstream services.

3. Tech Stack Rationale

Choice Rationale
Flutter Single codebase for Android and iOS. Strong ecosystem for maps (maplibre_gl). Dart compiles to native ARM, achieving 60fps rendering. Large community reduces hiring risk.
MapLibre GL Native Open-source fork of Mapbox GL Native. No telemetry, no API key required. Supports MVT vector tiles, client-side styling, and offline MBTiles. The privacy constraint eliminates Mapbox SDK.
Riverpod Compile-safe, testable state management. Better than bloc for this use case because map state is inherently reactive (viewport changes, location updates, search results). Riverpod's provider model fits naturally with dependency injection.
Drift Type-safe SQLite wrapper for Dart. Supports migrations, DAOs, and complex queries. Better than raw sqflite for maintainability. Compiles queries at build time.
Dio Full-featured HTTP client with interceptor support (for TLS enforcement, logging, caching headers). No third-party interceptors are used.
Rust + Actix-web Memory-safe, high-performance API gateway. No garbage collection pauses. Actix-web is the fastest Rust web framework by most benchmarks. Async runtime (Tokio) handles thousands of concurrent connections efficiently. Binary deployment (no JVM, no runtime).
Martin Written in Rust (same ecosystem as the gateway). Serves tiles directly from PostGIS with minimal configuration. Supports MBTiles and PMTiles. Actively maintained.
Photon Purpose-built for OSM geocoding. Better OSM coverage than Nominatim's API layer. Supports proximity-biased results. Self-hostable with no external dependencies beyond its Elasticsearch index.
OSRM Industry-standard open-source routing. Sub-second query times for continental distances. Well-documented API. Multiple profile support. Used by many OSM-based apps (known quantity).
PostGIS The standard for geospatial databases. Mature, well-indexed spatial queries. Native support in Martin. Ecosystem of import tools (osm2pgsql, imposm3).
Redis In-memory cache with TTL support. Simple, fast, well-understood. Reduces repeated expensive PostGIS queries.
Docker Compose Appropriate for self-hosted single-server deployment. All services defined declaratively. Easy for the target audience (self-hosters) to deploy and manage. Kubernetes would be overkill for the typical deployment scenario.

4. Mobile App Architecture

4.1 Folder Structure

lib/
├── main.dart                          # App entry point, ProviderScope
├── app.dart                           # MaterialApp, router config, theme
├── core/
│   ├── config/
│   │   ├── app_config.dart            # Backend URL, defaults
│   │   └── env.dart                   # Environment-specific config
│   ├── constants/
│   │   ├── map_constants.dart         # Zoom levels, default location
│   │   └── api_constants.dart         # Endpoint paths, timeouts
│   ├── error/
│   │   ├── app_exception.dart         # Base exception hierarchy
│   │   ├── error_handler.dart         # Global error handling
│   │   └── failure.dart               # Failure types for Either pattern
│   ├── network/
│   │   ├── dio_client.dart            # Dio instance, TLS config
│   │   ├── api_interceptor.dart       # Request/response logging (no PII)
│   │   └── connectivity.dart          # Online/offline detection
│   ├── theme/
│   │   ├── app_theme.dart             # Day/night Material themes
│   │   ├── map_styles.dart            # MapLibre style JSON references
│   │   └── colors.dart
│   └── utils/
│       ├── debouncer.dart
│       ├── coordinate_utils.dart
│       └── opening_hours_parser.dart
├── features/
│   ├── map/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   │   └── tile_repository.dart
│   │   │   └── datasources/
│   │   │       ├── tile_remote_source.dart
│   │   │       └── tile_cache_source.dart   # MBTiles SQLite
│   │   ├── domain/
│   │   │   ├── models/
│   │   │   │   ├── map_position.dart
│   │   │   │   └── map_marker.dart
│   │   │   └── repositories/
│   │   │       └── tile_repository.dart     # Abstract interface
│   │   ├── presentation/
│   │   │   ├── providers/
│   │   │   │   ├── map_controller_provider.dart
│   │   │   │   ├── location_provider.dart
│   │   │   │   └── theme_provider.dart
│   │   │   ├── widgets/
│   │   │   │   ├── map_view.dart
│   │   │   │   ├── compass_button.dart
│   │   │   │   ├── zoom_controls.dart
│   │   │   │   ├── attribution_widget.dart
│   │   │   │   └── location_button.dart
│   │   │   └── screens/
│   │   │       └── map_screen.dart
│   │   └── map_providers.dart               # Feature-level provider definitions
│   ├── search/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   │   └── search_repository.dart
│   │   │   ├── datasources/
│   │   │   │   ├── photon_remote_source.dart
│   │   │   │   ├── search_history_local_source.dart
│   │   │   │   └── offline_search_source.dart
│   │   │   └── models/
│   │   │       └── search_result_dto.dart
│   │   ├── domain/
│   │   │   ├── models/
│   │   │   │   ├── search_result.dart
│   │   │   │   └── search_history_item.dart
│   │   │   └── repositories/
│   │   │       └── search_repository.dart
│   │   ├── presentation/
│   │   │   ├── providers/
│   │   │   │   ├── search_provider.dart
│   │   │   │   └── search_history_provider.dart
│   │   │   ├── widgets/
│   │   │   │   ├── search_bar.dart
│   │   │   │   ├── search_result_tile.dart
│   │   │   │   └── recent_searches_list.dart
│   │   │   └── screens/
│   │   │       └── search_screen.dart
│   │   └── search_providers.dart
│   ├── routing/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   │   └── routing_repository.dart
│   │   │   ├── datasources/
│   │   │   │   ├── osrm_remote_source.dart
│   │   │   │   └── offline_routing_source.dart
│   │   │   └── models/
│   │   │       └── route_dto.dart
│   │   ├── domain/
│   │   │   ├── models/
│   │   │   │   ├── route.dart
│   │   │   │   ├── route_step.dart
│   │   │   │   ├── maneuver.dart
│   │   │   │   └── route_profile.dart
│   │   │   └── repositories/
│   │   │       └── routing_repository.dart
│   │   ├── presentation/
│   │   │   ├── providers/
│   │   │   │   ├── routing_provider.dart
│   │   │   │   ├── navigation_provider.dart
│   │   │   │   └── reroute_provider.dart
│   │   │   ├── widgets/
│   │   │   │   ├── route_summary_card.dart
│   │   │   │   ├── turn_instruction.dart
│   │   │   │   ├── profile_selector.dart
│   │   │   │   └── route_line_layer.dart
│   │   │   └── screens/
│   │   │       ├── directions_screen.dart
│   │   │       └── navigation_screen.dart
│   │   └── routing_providers.dart
│   ├── pois/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   │   └── poi_repository.dart
│   │   │   ├── datasources/
│   │   │   │   ├── poi_remote_source.dart
│   │   │   │   └── poi_local_source.dart
│   │   │   └── models/
│   │   │       └── poi_dto.dart
│   │   ├── domain/
│   │   │   ├── models/
│   │   │   │   ├── poi.dart
│   │   │   │   └── poi_category.dart
│   │   │   └── repositories/
│   │   │       └── poi_repository.dart
│   │   ├── presentation/
│   │   │   ├── providers/
│   │   │   │   └── poi_provider.dart
│   │   │   ├── widgets/
│   │   │   │   ├── poi_marker_layer.dart
│   │   │   │   ├── place_card.dart
│   │   │   │   └── opening_hours_display.dart
│   │   │   └── screens/
│   │   │       └── poi_detail_screen.dart
│   │   └── poi_providers.dart
│   ├── favorites/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   │   └── favorites_repository.dart
│   │   │   └── datasources/
│   │   │       └── favorites_local_source.dart
│   │   ├── domain/
│   │   │   ├── models/
│   │   │   │   ├── favorite.dart
│   │   │   │   └── favorite_group.dart
│   │   │   └── repositories/
│   │   │       └── favorites_repository.dart
│   │   ├── presentation/
│   │   │   ├── providers/
│   │   │   │   └── favorites_provider.dart
│   │   │   ├── widgets/
│   │   │   │   ├── favorite_list_tile.dart
│   │   │   │   └── save_favorite_dialog.dart
│   │   │   └── screens/
│   │   │       └── favorites_screen.dart
│   │   └── favorites_providers.dart
│   ├── offline/
│   │   ├── data/
│   │   │   ├── repositories/
│   │   │   │   └── offline_repository.dart
│   │   │   └── datasources/
│   │   │       ├── offline_remote_source.dart
│   │   │       └── offline_local_source.dart
│   │   ├── domain/
│   │   │   ├── models/
│   │   │   │   ├── offline_region.dart
│   │   │   │   └── download_progress.dart
│   │   │   └── repositories/
│   │   │       └── offline_repository.dart
│   │   ├── presentation/
│   │   │   ├── providers/
│   │   │   │   ├── offline_regions_provider.dart
│   │   │   │   └── download_manager_provider.dart
│   │   │   ├── widgets/
│   │   │   │   ├── region_list_tile.dart
│   │   │   │   ├── download_progress_bar.dart
│   │   │   │   └── region_selector_map.dart
│   │   │   └── screens/
│   │   │       └── offline_maps_screen.dart
│   │   └── offline_providers.dart
│   ├── sharing/
│   │   └── presentation/
│   │       ├── providers/
│   │       │   └── share_provider.dart
│   │       └── widgets/
│   │           └── share_sheet.dart
│   └── settings/
│       ├── presentation/
│       │   ├── providers/
│       │   │   └── settings_provider.dart
│       │   └── screens/
│       │       ├── settings_screen.dart
│       │       └── about_screen.dart
│       └── settings_providers.dart
├── database/
│   ├── app_database.dart              # Drift database definition
│   ├── tables/
│   │   ├── search_history_table.dart
│   │   ├── favorites_table.dart
│   │   ├── favorite_groups_table.dart
│   │   └── offline_regions_table.dart
│   └── daos/
│       ├── search_history_dao.dart
│       ├── favorites_dao.dart
│       └── offline_regions_dao.dart
└── router/
    └── app_router.dart                # GoRouter or declarative routing

4.2 State Management (Riverpod)

The app uses Riverpod with the following provider hierarchy:

Core Providers (app-wide):
  ├── dioClientProvider          → Dio instance (singleton)
  ├── databaseProvider           → Drift AppDatabase (singleton)
  ├── connectivityProvider       → Stream<bool> online/offline
  ├── locationProvider           → Stream<Position> from platform
  └── settingsProvider           → User preferences (theme, units, cache size)

Feature Providers (scoped per feature):
  ├── Map:
  │   ├── mapControllerProvider  → MapLibre controller
  │   ├── mapPositionProvider    → Current viewport (center, zoom, bearing)
  │   └── themeProvider          → Active map style (day/night/terrain)
  ├── Search:
  │   ├── searchQueryProvider    → StateProvider<String>
  │   ├── searchResultsProvider  → FutureProvider (debounced, calls repository)
  │   └── searchHistoryProvider  → StreamProvider (from Drift DAO)
  ├── Routing:
  │   ├── routeRequestProvider   → StateProvider<RouteRequest?>
  │   ├── routeResultsProvider   → FutureProvider (calls OSRM)
  │   ├── selectedRouteProvider  → StateProvider<int> (selected alternative index)
  │   └── navigationStateProvider → StateNotifierProvider (active navigation)
  ├── POIs:
  │   ├── visiblePoisProvider    → FutureProvider (bbox-based, auto-refreshes on viewport change)
  │   └── selectedPoiProvider    → StateProvider<Poi?>
  ├── Favorites:
  │   ├── favoritesListProvider  → StreamProvider (from Drift DAO)
  │   └── favoriteGroupsProvider → StreamProvider
  └── Offline:
      ├── availableRegionsProvider → FutureProvider (from backend)
      ├── downloadedRegionsProvider → StreamProvider (from Drift DAO)
      └── activeDownloadsProvider  → StateNotifierProvider (download manager)

Key patterns:

  • FutureProvider for one-shot async data fetches.
  • StreamProvider for reactive local data (Drift streams).
  • StateNotifierProvider for complex mutable state (navigation, downloads).
  • Provider for computed/derived values.
  • Feature providers use ref.watch to react to upstream changes (e.g., visiblePoisProvider watches mapPositionProvider).

4.3 Navigation

Use go_router for declarative navigation:

/                           → MapScreen (root)
/search                     → SearchScreen (overlay on map)
/directions                 → DirectionsScreen (origin/destination input)
/directions/navigate        → NavigationScreen (active turn-by-turn)
/poi/:osmType/:osmId        → POI detail (bottom sheet on map)
/favorites                  → FavoritesScreen
/settings                   → SettingsScreen
/settings/offline           → OfflineMapsScreen
/settings/about             → AboutScreen

The map remains persistent underneath all routes using a ShellRoute with the map as the shell. Search, POI details, and directions overlay the map as bottom sheets or panels.

4.4 Dependency Injection

Riverpod serves as the DI container. All dependencies are defined as providers:

// Core
final dioClientProvider = Provider<Dio>((ref) => createDioClient(ref));
final databaseProvider = Provider<AppDatabase>((ref) => AppDatabase());

// Repositories (depend on data sources)
final searchRepositoryProvider = Provider<SearchRepository>((ref) {
  return SearchRepositoryImpl(
    remote: ref.watch(photonRemoteSourceProvider),
    local: ref.watch(searchHistoryLocalSourceProvider),
    offline: ref.watch(offlineSearchSourceProvider),
    connectivity: ref.watch(connectivityProvider),
  );
});

This makes testing straightforward: override any provider in tests with a mock.


5. Backend Architecture (Rust / Actix-web)

5.1 Project Structure

backend/
├── Cargo.toml
├── src/
│   ├── main.rs                    # Server bootstrap, service wiring
│   ├── config.rs                  # Configuration from env vars
│   ├── routes/
│   │   ├── mod.rs
│   │   ├── tiles.rs               # GET /tiles/{layer}/{z}/{x}/{y}.pbf
│   │   ├── search.rs              # GET /api/search, GET /api/reverse
│   │   ├── routing.rs             # GET /api/route/{profile}/{coordinates}
│   │   ├── pois.rs                # GET /api/pois, GET /api/pois/{type}/{id}
│   │   ├── offline.rs             # GET /api/offline/regions, downloads
│   │   └── health.rs              # GET /api/health
│   ├── services/
│   │   ├── mod.rs
│   │   ├── martin_proxy.rs        # HTTP proxy to Martin
│   │   ├── photon_proxy.rs        # HTTP proxy to Photon
│   │   ├── osrm_proxy.rs          # HTTP proxy to OSRM instances
│   │   ├── poi_service.rs         # PostGIS POI queries
│   │   ├── offline_service.rs     # Region package management
│   │   └── health_service.rs      # Upstream health checks
│   ├── middleware/
│   │   ├── mod.rs
│   │   ├── cors.rs                # CORS configuration
│   │   ├── request_logger.rs      # Structured logging (no PII)
│   │   ├── rate_limiter.rs        # Token bucket per IP (IP not logged)
│   │   └── input_validator.rs     # Coordinate range checks, string sanitization
│   ├── cache/
│   │   ├── mod.rs
│   │   └── redis_cache.rs         # Redis get/set with TTL
│   ├── models/
│   │   ├── mod.rs
│   │   ├── poi.rs                 # POI structs, GeoJSON serialization
│   │   ├── region.rs              # Offline region metadata
│   │   └── error.rs               # Error types and API error response
│   └── db/
│       ├── mod.rs
│       └── postgres.rs            # Connection pool (deadpool-postgres), spatial queries
├── migrations/
│   └── 001_create_pois.sql
└── tests/
    ├── integration/
    └── common/

5.2 Route Configuration

// Simplified Actix-web route configuration
App::new()
    .wrap(middleware::cors())
    .wrap(middleware::request_logger())
    .wrap(middleware::rate_limiter())
    // Tiles (proxied to Martin)
    .route("/tiles/{layer}/{z}/{x}/{y}.pbf", web::get().to(tiles::get_tile))
    .route("/tiles/style.json", web::get().to(tiles::get_style))
    // Search (proxied to Photon)
    .route("/api/search", web::get().to(search::search))
    .route("/api/reverse", web::get().to(search::reverse))
    // Routing (proxied to OSRM)
    .route("/api/route/{profile}/{coordinates}", web::get().to(routing::route))
    // POIs (direct PostGIS queries)
    .route("/api/pois", web::get().to(pois::list_pois))
    .route("/api/pois/{osm_type}/{osm_id}", web::get().to(pois::get_poi))
    // Offline
    .route("/api/offline/regions", web::get().to(offline::list_regions))
    .route("/api/offline/regions/{id}/{component}", web::get().to(offline::download_component))
    // Health
    .route("/api/health", web::get().to(health::check))

5.3 Middleware

CORS:

  • Allow all origins (the mobile app's origin varies by deployment).
  • Allow methods: GET, HEAD, OPTIONS.
  • Allow headers: Content-Type, Accept, Accept-Encoding, Range.
  • Access-Control-Max-Age: 86400.

Request Logger:

  • Logs: HTTP method, path (without query string), response status, response time in ms.
  • Does NOT log: query parameters, client IP, request body, User-Agent, cookies.
  • Uses tracing crate with structured JSON output.
  • Log format: {"method":"GET","path":"/api/pois","status":200,"duration_ms":42}

Rate Limiter:

  • Token bucket algorithm, keyed by client IP (IP used only for bucket lookup, never logged or stored).
  • Default: 100 requests/second per IP, burst of 200.
  • Tile requests: 500 requests/second (tiles are cheap to serve from cache).
  • Returns 429 Too Many Requests with Retry-After header when exceeded.

Input Validator:

  • Validates coordinate ranges: latitude [-90, 90], longitude [-180, 180].
  • Validates zoom levels: [0, 18].
  • Validates limit parameters: enforces maximum values.
  • Sanitizes string inputs: max length 500 characters for search queries, strip control characters.
  • Returns 400 Bad Request with error details on validation failure.

5.4 Health Checks

The /api/health endpoint actively probes each upstream service:

Service Check Timeout
Martin GET /health on Martin's internal port 2s
Photon GET /api/search?q=test&limit=1 3s
OSRM (driving) GET /route/v1/driving/0,0;0.001,0.001?overview=false 3s
OSRM (walking) Same pattern 3s
OSRM (cycling) Same pattern 3s
PostgreSQL SELECT 1 2s

If any service is down, the endpoint still returns 200 OK but with that service's status as "degraded" or "down". This allows the mobile app to show degraded-mode indicators.


6. Deployment Architecture (Docker Compose)

6.1 Topology

# docker-compose.yml (structural overview)
services:
  gateway:        # Rust API gateway         port 8080 (internal)
  martin:         # Tile server              port 3000 (internal)
  photon:         # Geocoder                 port 2322 (internal)
  osrm-driving:   # OSRM driving profile     port 5001 (internal)
  osrm-walking:   # OSRM walking profile     port 5002 (internal)
  osrm-cycling:   # OSRM cycling profile     port 5003 (internal)
  postgres:       # PostgreSQL + PostGIS     port 5432 (internal)
  redis:          # Cache                    port 6379 (internal)
  caddy:          # Reverse proxy + TLS      ports 80, 443 (external)

6.2 Networking

  • All services are on a single Docker bridge network (maps_net).
  • Only caddy exposes ports to the host (80 for HTTPS redirect, 443 for TLS).
  • The gateway communicates with upstream services by Docker DNS names (martin:3000, photon:2322, etc.).
  • No service except caddy is reachable from outside the Docker network.

6.3 Volumes

Volume Service Purpose
pg_data postgres PostgreSQL data directory (persistent)
martin_config martin Martin configuration file
photon_data photon Photon/Elasticsearch index (persistent, ~2-20 GB depending on coverage)
osrm_data osrm-* Preprocessed OSRM graph files (persistent, per profile)
redis_data redis Redis AOF/RDB persistence (optional, cache can be cold-started)
offline_packages gateway Pre-built offline region packages served to mobile clients
caddy_data caddy TLS certificates (Let's Encrypt auto-provisioned)
caddy_config caddy Caddy configuration

6.4 Resource Allocation (for Netherlands-scale deployment)

Service CPU Limit Memory Limit
gateway 1 core 256 MB
martin 1 core 512 MB
photon 2 cores 2 GB
osrm-driving 1 core 1.5 GB
osrm-walking 0.5 core 1 GB
osrm-cycling 0.5 core 1 GB
postgres 2 cores 2 GB
redis 0.5 core 256 MB
caddy 0.5 core 128 MB
Total ~9 cores ~8.5 GB

7. Offline Architecture

7.1 Overview

Offline support has three pillars, each with its own storage format and data pipeline:

+-------------------------------------------+
|            Mobile Device Storage           |
|                                            |
|  +-------------+  +-----------+  +-------+ |
|  | MBTiles     |  | OSRM      |  | POI   | |
|  | (tiles.db)  |  | .osrm.*   |  | .db   | |
|  | SQLite      |  | files     |  | SQLite| |
|  +-------------+  +-----------+  +-------+ |
+-------------------------------------------+

7.2 Offline Tiles

  • Format: MBTiles (SQLite database with tile data as blobs).
  • The backend pre-generates MBTiles files per region using Martin's mbtiles tool or tilelive.
  • Zoom levels 0-16 are included (level 17-18 excluded to reduce size).
  • On the device, MapLibre GL Native is configured with a composite tile source:
    1. First, check the offline MBTiles database for the requested tile.
    2. If not found, check the LRU tile cache (also MBTiles format).
    3. If not found and online, fetch from the backend and store in cache.

7.3 Offline Routing

  • OSRM data files (.osrm, .osrm.cell_metrics, .osrm.partition, etc.) are downloaded per profile per region.
  • On the device, routing is performed using a compiled OSRM library accessed via Dart FFI (Foreign Function Interface) bindings to the OSRM C++ library.
  • The FFI binding exposes a minimal interface: route(profile_data_path, coordinates) -> RouteResult.
  • Fallback: if FFI integration proves too complex for v1.0, the app can use pre-calculated route graphs in a simplified Dart-native implementation (Dijkstra on a simplified road graph stored in SQLite). This is a degraded experience but ensures offline routing works.
  • POI data is downloaded as a SQLite database per region, containing the same schema as the PostGIS pois table.
  • Offline search uses SQLite FTS5 (full-text search) on the name and address columns.
  • Results are ranked by FTS5 relevance score, with proximity to the viewport center as a tiebreaker (using the Haversine formula in SQL).

7.5 Download Manager

  • Downloads are managed by a background isolate (Dart isolate) to prevent blocking the UI.
  • Each region download consists of multiple sequential component downloads (tiles, routing x3, POIs).
  • Supports HTTP Range headers for pause/resume.
  • Progress is tracked per component and aggregated per region.
  • Downloads persist across app restarts by storing state in Drift (download URL, bytes received, total bytes).

8. Privacy Architecture

Each privacy commitment from the spec is enforced as follows:

Commitment Technical Enforcement
No accounts No authentication middleware on backend. No session tokens, cookies, or Authorization headers. No user table in the database.
No telemetry CI pipeline runs dart pub deps --json and rejects any dependency matching a deny-list (Firebase, Sentry, Amplitude, etc.). Static analysis scans compiled binary for known analytics domain strings.
No third-party network calls Dio base URL is the single configured backend URL. Dio interceptor rejects any request not matching the base URL prefix. CI step: strings on the compiled APK/IPA, grep for known third-party domains — fail if any found.
On-device history Search history, favorites, and offline region metadata are stored in Drift (SQLite). No provider or repository ever sends this data over the network. Code review enforced.
Self-hosted backend Docker Compose includes all services. No SaaS API keys in configuration. Backend makes zero outbound network calls (all data is local).
Auditable Open-source. CI publishes dependency tree. Network monitor-friendly (single backend domain).
No PII logging (backend) request_logger middleware logs only: method, path (without query params), status, duration. The tracing subscriber is configured to redact any field named ip, query, user_agent, coordinates.
On-device encryption Android: database files stored in app-internal storage (encrypted by default on Android 10+; on Android 8-9, use EncryptedSharedPreferences for the SQLite encryption key with SQLCipher). iOS: files stored with NSFileProtectionComplete attribute (encrypted until first unlock).
TLS only Dio configured with baseUrl starting with https://. A custom SecurityContext rejects plaintext. On the backend, Caddy enforces HTTPS and redirects HTTP to HTTPS. HSTS header set.
No device fingerprinting No code reads IMEI, advertising ID, MAC address, or serial number. CI lint rule: any import of device_info_plus, android_id, or similar packages fails the build.

9. Error Handling Strategy

9.1 Mobile App

Network errors:

  • Connection timeout / no connectivity: switch to offline mode automatically. Show "Offline mode" banner. Serve tiles from cache, search from local DB, routing from local OSRM data (if available).
  • HTTP 429 (rate limited): retry after Retry-After duration with exponential backoff (max 3 retries).
  • HTTP 5xx: show a non-intrusive snackbar ("Service temporarily unavailable"). Retry with exponential backoff.
  • HTTP 404 (tile): render blank tile area, do not retry.

Service-specific degradation:

Service Down User Experience
Martin (tiles) Map shows cached tiles only. Uncached areas are blank. Banner: "Some map areas unavailable."
Photon (search) Search falls back to offline POI database (if downloaded). Otherwise: "Search unavailable. Try offline maps."
OSRM (routing) Routing falls back to offline OSRM data (if downloaded). Otherwise: "Routing unavailable."
PostGIS (POIs) POI markers not shown. POI detail returns "Details unavailable."
Entire backend Full offline mode. All features work if region is downloaded. Otherwise: cached tiles only, no search, no routing.

Local errors:

  • SQLite corruption: detect on open, offer to clear cache (favorites are backed up separately).
  • Disk full: warn user before download starts (check 80% threshold). If cache is full, LRU eviction runs automatically.

9.2 Backend API

All error responses use a standard format:

{
  "error": {
    "code": "INVALID_BBOX",
    "message": "Bounding box coordinates are out of valid range."
  }
}

Error code mapping:

HTTP Status Code When
400 INVALID_PARAMETER Query parameter fails validation
400 INVALID_BBOX Bounding box coordinates out of range
400 INVALID_COORDINATES Route coordinates out of range
400 MISSING_PARAMETER Required parameter missing
404 NOT_FOUND POI, tile, or region not found
429 RATE_LIMITED Client exceeded rate limit
502 UPSTREAM_ERROR Martin/Photon/OSRM returned an error
503 SERVICE_UNAVAILABLE Upstream service is down
500 INTERNAL_ERROR Unexpected server error

The gateway never exposes internal error details (stack traces, database errors) to the client.


10. Security

10.1 TLS

  • Caddy auto-provisions TLS certificates via Let's Encrypt (ACME).
  • Minimum TLS version: 1.2. Preferred: 1.3.
  • HSTS header: Strict-Transport-Security: max-age=63072000; includeSubDomains.
  • HTTP requests to port 80 are 301-redirected to HTTPS.
  • Internal Docker network communication is plaintext (acceptable: all services are on the same host, in the same Docker network, not exposed externally).

10.2 No Authentication (by design)

  • The backend has no authentication mechanism. This is intentional.
  • The backend is meant to be deployed on a private network or behind a VPN/firewall.
  • If public exposure is needed, the deployer can add HTTP Basic Auth or client certificates at the Caddy layer. This is documented but not built into the application.

10.3 Input Validation

All user-supplied input is validated at the gateway before proxying:

Input Validation
Zoom level (z) Integer, [0, 18]
Tile coordinates (x, y) Integer, >= 0, <= 2^z - 1
Latitude Float, [-90.0, 90.0]
Longitude Float, [-180.0, 180.0]
Bounding box 4 floats, valid lat/lon, min < max
Search query (q) String, max 500 chars, control chars stripped
limit Integer, [1, max_for_endpoint]
Routing profile Enum: driving, walking, cycling
Coordinates (routing) 2-7 coordinate pairs (origin + up to 5 waypoints + destination)
OSM type Enum: N, W, R
OSM ID Positive integer
lang 2-char ISO 639-1, validated against allow-list

10.4 Rate Limiting

  • Applied per source IP using an in-memory token bucket (not stored in Redis to avoid logging IPs).
  • Default limits (configurable via environment variables):
    • Tile requests: 500/s per IP, burst 1000.
    • API requests: 100/s per IP, burst 200.
  • Returns 429 with Retry-After header.
  • The rate limiter state is ephemeral (lost on restart), which is acceptable.

10.5 Dependency Security

  • Backend: cargo audit runs in CI to detect known vulnerabilities in Rust dependencies.
  • Mobile: dart pub outdated and manual review of transitive dependencies.
  • No native dependencies beyond MapLibre GL Native and (optionally) OSRM FFI.
  • Docker images use minimal base images (rust:slim for build, debian:bookworm-slim for runtime).

10.6 Content Security

  • Martin tiles are binary protobuf; no injection risk.
  • Photon and OSRM responses are validated JSON. The gateway re-serializes POI responses from PostGIS to prevent SQL injection artifacts from reaching the client.
  • The gateway uses parameterized queries (via tokio-postgres / sqlx) for all PostGIS access. No string interpolation in SQL.