Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mobile Bridge Development

Qleany can generate a mobile bridge crate that wraps your Rust backend for consumption by iOS (Swift) and Android (Kotlin) apps. The bridge uses UniFFI to produce a synchronous FFI surface, with platform-native async wrappers generated as Swift and Kotlin source files.

Enabling Mobile Targets

Add rust_ios and/or rust_android to your manifest’s ui section:

ui:
  rust_cli: true
  rust_slint: true
  rust_ios: true       # generates mobile_bridge + Swift wrappers
  rust_android: true   # generates mobile_bridge + Kotlin wrappers

Either flag triggers generation of the mobile_bridge crate. Both flags together generate wrappers for both platforms.

Platform-specific build and integration guides are generated alongside the crate:

  • iOS: crates/mobile_bridge/README-iOS.md (prerequisites, cargo-swift workflow, Xcode integration)
  • Android: crates/mobile_bridge/README-Android.md (prerequisites, cargo-ndk workflow, Gradle setup, ProGuard rules)

Generated Structure

crates/mobile_bridge/
├── Cargo.toml
├── uniffi.toml                       # UniFFI custom type mappings
├── src/
│   ├── lib.rs                        # Module declarations + re-exports
│   ├── backend.rs                    # MobileBackend lifecycle + listeners
│   ├── custom_types.rs               # MobileDateTime
│   ├── errors.rs                     # MobileError enum
│   ├── events.rs                     # MobileEventKind, dispatch, fan-out
│   ├── undo_redo_commands.rs         # Undo/redo wrappers
│   ├── {entity}_commands.rs          # Per-entity CRUD + relationships
│   └── {feature}_commands.rs         # Per-feature use case wrappers
├── tests/
│   └── integration_tests.rs
├── swift/
│   ├── MobileBackend+Async.swift     # Swift async/await wrappers
│   └── MobileBridgeTests.swift       # XCTest suite
├── kotlin/
│   ├── MobileBackendAsync.kt         # Kotlin suspend wrappers
│   └── MobileBridgeTest.kt           # JUnit test suite
├── README-iOS.md                     # iOS build guide
└── README-Android.md                 # Android build guide

Architecture

The mobile bridge sits between platform code and the frontend crate:

     Swift / Kotlin (async)
              │
     ┌────────┴────────┐
     │  mobile_bridge   │   Synchronous UniFFI surface
     │  (cdylib/static) │
     └────────┬────────┘
              │
     ┌────────┴────────┐
     │    frontend      │   Commands, AppContext, FlatEvent
     └────────┬────────┘
              │
     ┌────────┴────────┐
     │  common, direct_ │   Entities, repos, database
     │  access, features│
     └─────────────────┘

Key design decisions:

  • The Rust core stays fully synchronous. No async runtime.
  • Platform-native async (Task.detached on iOS, withContext(Dispatchers.IO) on Android) wraps the synchronous calls.
  • All operations go through frontend::commands to ensure undo/redo registration and event emission.

MobileBackend

The entry point is the MobileBackend object. It owns the AppContext (database, event hub, undo/redo manager) and provides all operations as methods.

// Swift
let backend = MobileBackend()
let item = try await backend.createItemAsync(stackId: nil, dto: dto, ownerId: 1, index: 0)
backend.shutdown()
// Kotlin
val backend = MobileBackend()
scope.launch {
    val item = backend.createItemAsync(stackId = null, dto = dto, ownerId = 1uL, index = 0)
}
// Later, when done:
backend.shutdown()

Lifecycle

  1. MobileBackend(): creates database, event hub, starts dispatch thread
  2. Use CRUD/feature/undo-redo methods
  3. shutdown(): stops dispatch thread, releases resources

Entity Operations

For each entity, the bridge exposes:

  • create_{entity} / create_{entity}_multi (with owner)
  • create_orphan_{entity} / create_orphan_{entity}_multi
  • get_{entity} / get_{entity}_multi / get_all_{entity}
  • update_{entity} / update_{entity}_multi
  • remove_{entity} / remove_{entity}_multi
  • get_{entity}_relationship / get_{entity}_relationship_many
  • get_{entity}_relationship_count / get_{entity}_relationship_in_range
  • set_{entity}_relationship / move_{entity}_relationship

Undoable entities accept an optional stack_id parameter.

Undo/Redo

  • undo(stack_id) / redo(stack_id)
  • can_undo(stack_id) / can_redo(stack_id) (lightweight, UI-thread-safe)
  • create_new_stack() / delete_stack(stack_id)
  • begin_composite(stack_id) / end_composite() / cancel_composite()
  • clear_stack(stack_id) / clear_all_stacks()
  • get_stack_size(stack_id) (lightweight, UI-thread-safe)

Feature Commands

Feature use cases follow one of five patterns depending on their properties:

PatternPropertiesSignature
1non-undoable, has DTO infn(dto) -> Result
2non-undoable, no DTO infn() -> Result
3undoable, has DTO infn(stack_id, dto) -> Result
4undoable, no DTO infn(stack_id) -> Result
5long operationstart_*(), get_*_progress(), get_*_result()

Long operations can be cancelled with cancel_operation(operation_id).

Event System

Event Listener

Register a callback to receive all events:

class MyListener: MobileEventListener {
    func onEvent(event: MobileEvent) {
        switch event.kind {
        case .itemCreated, .itemUpdated:
            refreshItems()
        case .undoPerformed, .redoPerformed:
            refreshAll()
        default: break
        }
    }
}
backend.setEventListener(listener: MyListener())

Auto-Save Listener

Register a callback that fires on any entity mutation (create, update, remove). The typical pattern is to call a custom Save feature use case that serializes the in-memory state to disk (e.g. writing the manifest YAML, exporting to a file, or persisting to a platform store).

Since on_save_needed fires on every single mutation, debouncing is essential: you don’t want to write to disk on every keystroke. A common approach is to set a dirty flag and flush after a short delay (e.g. 500ms to 2s of inactivity), or on app backgrounding.

class AutoSaver: MobileAutoSaveListener {
    private var saveWorkItem: DispatchWorkItem?
    private let backend: MobileBackend

    init(backend: MobileBackend) { self.backend = backend }

    func onSaveNeeded() {
        // Called on background thread: debounce before saving
        saveWorkItem?.cancel()
        let work = DispatchWorkItem { [weak self] in
            guard let self else { return }
            // Call your custom "Save" feature use case
            try? self.backend.save(dto: MobileSaveDto(filePath: currentFilePath))
        }
        saveWorkItem = work
        DispatchQueue.global().asyncAfter(deadline: .now() + 1.0, execute: work)
    }
}
backend.setAutoSaveListener(listener: AutoSaver(backend: backend))
class AutoSaver(
    private val backend: MobileBackend,
    private val scope: CoroutineScope
) : MobileAutoSaveListener {
    private var saveJob: Job? = null

    override fun onSaveNeeded() {
        // Called on background thread: debounce before saving
        saveJob?.cancel()
        saveJob = scope.launch {
            delay(1000)
            withContext(Dispatchers.IO) {
                // Call your custom "Save" feature use case
                runCatching { backend.save(MobileSaveDto(filePath = currentFilePath)) }
            }
        }
    }
}
backend.setAutoSaveListener(AutoSaver(backend, lifecycleScope))

The Save use case itself is a custom feature you define in your manifest. Qleany generates the controller, DTO, and UoW scaffolding. The implementation typically serializes entities back to whatever format your app uses (YAML manifest, JSON file, SQLite, etc.).

Sole Consumer Constraint

Flume channels are MPMC (competing consumers). The MobileBackend takes the only receiver from the EventHub. Do not instantiate EventHubClient or call start_event_loop() alongside MobileBackend.

Custom Types

MobileDateTime

chrono::DateTime<Utc> is mapped to platform-native types via UniFFI:

  • Swift: Date (via timeIntervalSince1970)
  • Kotlin: java.time.Instant (via toEpochMilli)

MobileError

All fallible methods return MobileError:

  • OperationFailed { message }: wraps anyhow::Error
  • NotFound { entity, id }: entity not found

Async Wrappers

Every method that touches the database has a generated async variant:

  • Swift: *Async() methods using Task.detached
  • Kotlin: *Async() suspend functions using withContext(Dispatchers.IO)

Lightweight methods (can_undo, can_redo, get_stack_size, shutdown, cancel_operation) stay synchronous. They are safe to call from the UI thread.

Testing

Integration tests run without a device or emulator:

# Rust tests
cargo test -p mobile_bridge

# Swift tests (requires Xcode)
swift test

# Kotlin tests (requires Gradle)
./gradlew test

In-Memory Constraint

The entire dataset must fit in memory. This is appropriate for document-oriented apps under ~100MB working set. For larger datasets, consider pagination via get_*_relationship_in_range.