Skip to content

vdribeiro/AppBuilder

Repository files navigation

App Builder

This is a ready-to-go Offline First Compose Multiplatform starter kit built to help you spin up full-stack apps fast. It bundles a Compose Multiplatform frontend for Android, iOS, Desktop, and Web right alongside a built-in backend, plus an admin app. Everything is pre-wired so you can skip the boilerplate and go straight to shipping for every platform from a single codebase.

Shameless Plug

For me, writing code is like writing poetry. It’s about crafting logic for humans, not just making the machine understand, and expressing complex ideas with rhythm, economy, and elegance. This project is a labor of love, and I would deeply appreciate any feedback you might have.

Table of Contents

Architecture

Kotlin Multiplatform project following a Layered Clean Architecture designed around a Unidirectional Dependency Flow.

Supported Platforms

  • Android
  • iOS
  • Windows
  • macOS
  • Linux
  • Web

Platform Source Sets

Source Set Platform
androidMain Android API 26+
appleMain iOS 16+ / macOS
desktopMain JVM 21
webMain WASM-JS
server JVM 21

Main Tech Stack

Modules

  • shared: Code that is reused across all target platforms (Client, Admin and Server).
  • sharedTest: Test fixtures, base test cases, and mocks reused by every other module's test suite.
  • design: Design System where all UI components live.
  • appCore: Reusable Compose Multiplatform app infrastructure shared by clientApp and adminApp.
  • clientApp: The end-user client app for Android, iOS, Desktop, and Web.
  • adminApp: Desktop admin dashboard for managing server configuration, feature flags and push notifications.
  • server: The backend for the application.

The packages across all modules obey the same structure.

Package Responsibilities

Each package has a specific isolated responsibility, and dependencies always point in a single direction down the stack.

  • core: Business logic agnostic implementations. It is the foundation and cannot depend on any other layer.
  • data: Responsible for data persistence and retrieval. Provides infrastructure for Network, Database and Storage. It can only depend on core.
  • domain: Business workflows and rules. It can depend on data and core.
  • ui: User-facing presentation layer. It can depend on domain, data and core.
  • test: Test utilities like annotations and fake data shared across test source sets. It can depend on all layers, domain, data, core, ui.

core

  • config: Remote configs with containers for feature flags and configurations.
  • flow: Coroutine dispatchers.
  • locale: Localization and date/time formatting.
  • platform: OS-specific APIs.
  • media: Media implementations.
  • security: Cryptography and UUID utilities.
  • telemetry: Logging and crash reporting.

data

  • device: Device specific code.
  • http: Ktor configuration and network logic.
  • serializer: JSON parsing and serialization.
  • database: SQLDelight or Exposed implementations, tables and drivers.
  • resource: Resource index.
  • notification: Notification system implementation.
  • signal: Cross-instance signaling for push delivery.
  • storage: File system access.
  • translation: Translation related tools.

domain

  • clock: Clock services.
  • push: Broadcast and Push services.
  • scheduler: Scheduler implementation.
  • translation: Translation service.
  • usecase: Implementation of specific business workflows.
  • route: Defines the routing endpoints for the server.

ui

  • core: UI core components like Text or Button, that provide the building blocks for composite components.
  • component: UI composite components.
  • lifecycle: Platform-aware lifecycle observers.
  • push: Push composables.
  • media: Media composables.
  • modifier: Modifier extensions.
  • navigation: Routing logic.
  • permission: System permissions management.
  • screen: UI entry points built with components. Each screen is also a sub-package containing the composable and respective store for state management, all co-located.

test

Testing suites, fake data and annotations.

Modules Structure

Shared

core

  • config
    • ClientFlags: Client feature flag container.
    • ClientConfigs: Client config container.
    • ServerFlags: Server feature flag container.
    • ServerConfigs: Server config container.
  • flow
    • Dispatcher: Provides Main, Default and IO coroutine dispatchers as mutable properties so tests can substitute their own implementations.
  • locale: Localization and date/time formatting.
    • DateTime: Utilities for time, e.g. getting the current time in UTC/ISO8601.
  • platform: OS-specific APIs.
    • Platform: Provides a snapshot of the current execution environment with a sealed interface that defines the possible platforms that the application can run on.
  • security: Cryptography and UUID utilities.
    • Uuid: UUID generation via the best available platform algorithm.
  • telemetry: Logging and crash reporting.
    • Telemetry: Hub for dispatching telemetry data, including logs, error reports, and user feedback, by iterating over pluggable TelemetryEngine implementations.
    • SentryLogger: Sentry integration.

data

  • http: Ktor configuration and network logic.
    • Header: Sealed class of all header keys.
    • Query: Sealed class of all query parameter keys.
    • URL: Sealed class of all remote endpoints, serving as a centralized registry for API routing.
    • HttpRequest: Represents an HTTP request with associated metadata.
  • serializer: JSON parsing and serialization.
    • Json: Helpers for encoding/decoding.

domain

Contains all the domain models.

test

Annotations used to exclude tests from coverage reports.

Shared Test

core

  • telemetry: Logging and crash reporting.
    • MockLogger: A mock implementation of the TelemetryEngine interface.

ui

  • lifecycle: Platform-aware lifecycle observers.
    • OverridableLifecycleOwner: A LifecycleOwner that can be manually set to a specific state.

test

  • FakeData: A collection of fake data for testing.
  • SharedTestCase: Class that provides the shared hermetic testing environment, providing helpers to run unit and UI tests.
  • PlatformTestCase: Class that extends SharedTestCase for platform-specific test setup.
  • LazyData: Mutex-protected suspend-once-cache-forever lazy loader.
  • Extensions: Testing helper extensions.

Design

data

  • translation: Translation related tools.
    • TranslationCache: A cache dedicated to translations.

ui

  • Color: Defines LocalColorScheme for light and dark themes.
  • Typography: Defines LocalTypography for the text fonts.
  • Shapes: Defines LocalShapes for the shape system.
  • Translations: Composable helpers to read from TranslationCache. Provides LocalTranslationState.
  • Theme: Main app theme definition that uses the previously described CompositionLocals.
  • Preview: Wrapper composable that the application theme for accurate rendering in previews.
  • core: UI core components like Text or Button, that provide the building blocks for composite components.
  • component: UI composite components.

App Core

core

  • media: Media implementations.
    • AudioPlayer: Manages playlist deduplication and shuffling.
    • Camera: Manages photo capture and video recording.
  • locale: Localization and date/time formatting.
    • Language: App default language and other language listings.
    • Locale: Methods to get the current system language, locale-aware datetime formatting, and a Flow that emits on locale changes.
  • platform: OS-specific APIs.
    • Development: Development mode flag.
    • AppDataPath: Indicates the absolute path to the application's directory.
    • Loop: Suspending, non-blocking polling loop helper.
  • security: Cryptography and UUID utilities.
    • Cryptography: Cryptography methods like encrypt/decrypt and hashing.
  • telemetry: Logging and crash reporting.
    • PlatformLogger: A TelemetryEngine implementation for native system console outputs.

data

  • device: Device specific code.
    • Device: Generates and fetches a device installation uuid.
  • http: Ktor configuration and network logic.
    • plugin: Plugins used by the Ktor Http engine.
    • HttpClientFactory: Creates a HttpClient with the plugins.
    • HttpClientEngine: Creates the Http client engine.
    • NoOpHttpClientEngine: A no-op implementation of HttpClientEngine. Returns 204 for every request.
    • Network: Network status helpers.
    • Http: Extensions to execute type-safe requests and decode the response into a HttpResult<Success|Error>.
  • notification: Notification system implementation.
    • Notification: Platform specific implementation of a trigger of a native system notification defined by NotificationData.
    • NotificationPayload: Exposes a Flow used to bridge the Native OS notifications interactions with the common code.
  • storage: File system access.
    • File: Declarations for suspending save/load/delete file operations.
    • Storage: Implements StorageFile interface to manage the state and persistence of key-value files. Uses a Mutex-protected I/O to prevent race conditions and a read-only stream as an in-memory cache for faster fetch operations.
    • StorageKey: Sealed class of keys used in StorageFile implementations.
    • IO: Helper functions to save/load objects.

ui

  • component: UI composite components.
    • Store: ViewModel with a StateFlow<State> as the single source of truth for the UI and reducer override to process actions from the UI.
  • lifecycle: Platform-aware lifecycle observers.
    • Lifecycle: Composable that registers foreground/background callbacks, with an optional recomposition key.
  • media: Media composables.
    • AudioPlayer: Registers a lifecycle callback for audio playback. Provides LocalAudioPlayer.
    • Camera: Composable for camera previews. Provides LocalCamera.
  • modifier: Modifier extensions.
    • PointerListener: A Modifier that handles mouse interaction.
  • navigation: Routing logic.
    • scene: Scene strategies used to render a list of entries.
    • Navigator: Implementation of Router to handle routing logic and manage the navigation backstack.
    • NoOpRouter: A no-op implementation of Router.
    • Routing: Hosts the navigation display and handles back gesture/mouse-back.
  • permission: System permissions management.
    • PermissionManager: Manager for checking and requesting system permissions declared in Permission. The latter also provides LocalPermissionManager.
  • screen: UI entry points built with components. Each screen is also a sub-package containing the composable and respective store for state management, all co-located.
    • Screen: Wrapper composable that provides the foundational UI.

root level

  • AppInfo: Holds the app related data like id, name and version of the app consuming this module; should be initialized once at startup by the consumer app.

Client App

core

  • locale: Localization and date/time formatting.
    • Clock: Observes system clock changes.

data

  • database: SQLDelight implementations, tables and drivers.
    • adapter: List of adapters used to marshal and map data types to and from a database.
    • DatabaseFactory: Creates the main AppDatabase with the adapters.
    • SqlDriver: Creates the database driver.
    • NoOpSqlDriver: A no-op implementation of SqlDriver.
    • Database: Schema aliases and database extension helpers.
    • SqlIO: Query helpers.
  • resource: Resource index.
    • AudioResource: Sealed class functioning as a resource index for audio in commonMain/resources/tracks.
    • ImageResource: Sealed class functioning as a resource index for images in commonMain/resources/drawable.
    • JsonResource: Sealed class functioning as a resource index for JSONs in commonMain/composeResources/files.
    • ResourceLoader: Loads resources via the Compose Resources API.

domain

  • clock: Clock services.
    • ClockService: Tracks whether the device clock can be trusted. Implemented by ClockManager.
  • push: Broadcast and Push services.
    • BroadcastService: Broadcast service that listens for push notifications. Implemented by BroadcastManager.
    • PushService: Push service that listens for user push notifications. Implemented by PushManager.
    • PushProvider: Provider to collect the push token and handle push payloads.
  • scheduler: Scheduler implementation.
    • Job: A persistent background execution unit handled by the scheduling subsystem.
    • JobFactory: Translates Jobs into executable runtime actions and is implemented by JobProvider.
    • Scheduler: Orchestrates dispatch logic by managing job queues destined for persistent execution and is implemented by JobScheduler.
    • JobResult: Represents the final outcome of a Job.
  • translation: Translation service.
    • TranslationService: Service to monitor locale and translations. Implemented by TranslationManager.
  • usecase: Implementation of specific business workflows. Each use case is a sub-package containing the interface and its gateway implementation.
    • Gateways: Aggregates all gateways under the UseCases file.

ui

  • App: Main composable that assembles the application UI and acts as the top-level container for the user-facing elements.
  • navigation: Routing logic.
    • provider: Definitions of the navigation routes.
    • Screen: Sealed interface that enumerates all destinations.
    • Navigation: Composables that set up the navigation and define the possible navigation destinations within the app.
    • DeepLink: Rebuilds the navigation backstack from an action, like a tapped-notification payload.
  • push: Push composables.
    • Push: Composable that pauses/resumes the WebSocket and SSE connections based on app foreground state, per platform. See Client Connection Lifecycle.
  • component: UI components implementations. Each component is a sub-package containing the composable and respective store for state management, all co-located.
  • screen: UI entry points built with components. Each screen is also a sub-package containing the composable and respective store for state management, all co-located.

root level

  • Dependency: Manual dependency injection. This class wires everything together. It initializes the full stack: SqlDriverAppDatabase, HttpClientEngineHttpClient, to be used by the Scheduler and UseCases via Gateways.
  • Application: Entry point of the application. It initializes the Dependency graph and other app services. Provides dependency Flows that can be observed to ensure initialization is complete.

Admin App

domain

  • usecase: Implementation of specific business workflows. Each use case is a sub-package containing the interface and its gateway implementation.
    • Gateways: Aggregates all gateways under the UseCases file.

ui

  • Main: Main composable that assembles the application UI and acts as the top-level container for the user-facing elements.
  • navigation: Routing logic.
    • provider: Definitions of the navigation routes.
    • Screen: Sealed interface that enumerates all destinations.
    • Navigation: Composables that set up the navigation and define the possible navigation destinations within the app.
  • component: UI components implementations. Each component is a sub-package containing the composable and respective store for state management, all co-located.
  • screen: UI entry points built with components. Each screen is also a sub-package containing the composable and respective store for state management, all co-located.

root level

  • Dependency: Manual dependency injection. This class wires everything together.
  • Application: Entry point of the application. It initializes the Dependency graph.

Server

core

  • config: Remote configs with containers for feature flags and configurations.
  • platform: OS-specific APIs.
    • Env: Environment variables definitions.
    • Property: Property definitions.
  • security: Cryptography and UUID utilities.
    • Cryptography: Cryptography methods to hash and verify passwords.
  • telemetry: Logging and crash reporting.
    • ServerLogger: A TelemetryEngine implementation for server outputs.

data

  • database: Exposed implementations and tables.
    • table: Database tables definitions.
    • DatabaseFactory: Creates a R2dbcDatabase.
    • Database: Database extension helpers.
  • http: Ktor configuration and network logic.
    • plugin: Plugins used by the Ktor Http engine.
    • HttpServerFactory: Sets the Http Server with the plugins.
  • signal: Cross-instance signaling for push delivery. See Multi-Instance Scaling.
    • InstanceSignal: Pub/Sub interface for signaling events to every server instance observing a channel.
    • LocalSignal: Single instance implementation.
    • PostgresSignal: Multi-instance implementation backed by Postgres LISTEN/NOTIFY.

domain

  • push: Broadcast and Push services.
    • BroadcastService: Broadcast service to send push notifications. Implemented by BroadcastManager.
    • PushService: Push service to send user push notifications. Implemented by PushManager.
    • FcmService: Sends push payloads via Firebase Cloud Messaging. Implemented by FcmManager.
  • route: Defines the routing endpoints for the server.
    • provider: Aggregates the routes.
    • Routing: Sets all routes defined in the provider package.
    • Extensions: Route helpers.
  • usecase: Implementation of specific business workflows. Each use case is a sub-package containing the interface and its gateway implementation.
    • Gateways: Aggregates all gateways under the UseCases file.

test

Fake data and demo setup.

root level

  • Dependency: Manual dependency injection. This class initializes the R2dbcDatabase to be used by UseCases via Gateways.
  • Application: Entry point of the server application. It initializes the Dependency graph and other app services.

Development Workflow

Environment Setup

By default, signing keys and some envs are read from a local.properties file in the root directory. Change this method at your leisure.

  • Android Signing: android.storeFile, android.keyAlias, android.keyPassword, android.storePassword.
  • Mac Notarization: mac.sign.identity, mac.notarization.appleId, mac.notarization.teamId, mac.notarization.password.
  • Sentry Monitoring: sentryDsn for production monitoring.
  • Web Push: web.vapidKey, web.firebaseConfig.

The server instead reads its configuration from OS environment variables via Env.kt:

  • Server: DEVELOPMENT, PORT, SENTRY_DSN.
  • Database: DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD.
  • JWT: JWT_ISSUER, JWT_AUDIENCE, JWT_SECRET.
  • Push: GCP_PROJECT_ID.

Push Notifications

Delivery uses two independent channels per push type, so devices are covered whether they are currently reachable over a live connection or not:

  • Broadcast (BroadcastService) sends to both the SSE stream and the FCM broadcast topic, to every listener, regardless of which devices are connected to which.
  • Push (PushService) sends to a user's live WebSocket connections or via FCM. There is no confirmed-delivery handoff between the two channels: if a WebSocket send fails after the FCM exclusion list was already computed, that specific notification can be missed until the next one triggers a fresh, now-accurate presence check.

Any payload that does happen to arrive over both channels is deduplicated client-side by PushProvider, which remembers recently handled payload UUIDs.

Multi-Instance Scaling

Both services are built to run correctly behind multiple server instances, with no state that only one instance can see:

  • Connection tickets (TicketTable / TicketUseCases): the short-lived, single-use ticket a client exchanges for a WebSocket connection is persisted rather than held in memory, so a ticket minted by one instance is redeemable on any other. Consumption is atomic, so a ticket can never be redeemed twice even by concurrent requests hitting different instances.
  • Connection presence (ConnectionTable / ConnectionUseCases): which devices are connected, and to which instance, lives in the database instead of a local map. Each instance heartbeats its own rows and sweeps rows left behind by crashed instances, so the FCM-exclusion decision above is correct globally, not just for sockets held by the instance handling sending pushes.
  • Cross-instance signaling (InstanceSignal): a notification saved, or a broadcast sent, on one instance must still reach a WebSocket/SSE connection held by a different instance. PostgresSignal uses Postgres LISTEN/NOTIFY, backing every channel with a single shared, self-healing connection regardless of how many observers subscribe to it. The signal only carries deliveries to other instances: an instance sends to its own SSE sessions directly and tags each relayed payload with its instance id, so it skips its own echo and a signal failure can never black out the sessions it holds itself.

Client Connection Lifecycle

RegisterPushLifecycle decides whether a platform keeps its WebSocket/SSE connections open while the app is backgrounded, or relies on FCM alone:

  • Android / iOS: connections close on background and reopen on foreground. FCM is the only channel while backgrounded. It's a store-and-forward mechanism built to wake up a backgrounded or killed app, not for high-frequency low-latency delivery, and the OS suspends sockets shortly after backgrounding anyway.
  • Web: connections stay open while the tab is open, alongside an FCM service worker for background delivery.
  • Desktop: there is no FCM client SDK for JVM desktop apps, so connections always stay open for the app's lifetime and FCM is never used on this platform.

All commands assume the Firebase CLI is installed and logged in (firebase login).

  1. Create or reuse a Firebase project and register an app per platform, e.g. firebase apps:create ANDROID <appId>, firebase apps:create IOS <appId>, firebase apps:create WEB "<appName>".
  2. Android: firebase apps:sdkconfig ANDROID <appId> and save the result as clientApp/google-services.json.
  3. iOS: firebase apps:sdkconfig IOS <appId> and save the result as iosApp/iosApp/GoogleService-Info.plist.
    • In Xcode, open iosApp.xcworkspace → select the iosApp target → Signing & Capabilities → pick a Team → + Capability → add Push Notifications. This registers the capability on the App ID and wires the existing iosApp.entitlements file via the CODE_SIGN_ENTITLEMENTS build setting.
    • Info.plist already declares UIBackgroundModes: remote-notification, needed for silent payloads to reach the app while backgrounded. Visible payloads are rendered by iOS itself from the APNs alert field and don't need this.
  4. Web: firebase apps:sdkconfig WEB <appId> and set the result, minified to a single line, as web.firebaseConfig in local.properties (e.g. web.firebaseConfig={"apiKey":"...","projectId":"...",...}).
    • Generate a VAPID key: Firebase Console → Project Settings → Cloud Messaging → Web configuration → "Generate key pair" (no CLI/API path exists for this). Set it as web.vapidKey in local.properties.

Feature Creation

New features should be gated behind a flag. Below are the steps to create a new isolated feature.

Shared

  1. Domain: Add the domain file(s) in the domain package.
  2. Entity: Add the entity to EntityType for offline support.
  3. Flag: Add the client and server flags for the feature: val {feature}: Boolean.

Design

  1. UI Components: Add the needed components.

Client App

  1. Components with Store: Create a new package ui/component/{component} with files {Component}, {Component}StateAction, {Component}Store.
  2. Screen: Create a new package ui/screen/{feature} with files {Feature}Screen, {Feature}StateAction, {Feature}Store.
  3. UseCase: Create the use cases in domain/usecase and the needed .sq files in the folder sqldelight.
  4. Scheduler: Create the needed jobs in domain/scheduler/JobProvider for offline capabilities.
  5. Navigation: Add to the ui/navigation package the new screen in the sealed class Screen, the new route in the provider package and the link it in the composable navigation in Navigation, gated by the feature flag.

Admin App

  1. Flag: Add the client and server flags to the dashboard.

Server

  1. Database: Add the feature table to data/database/table and to the table list in Database.
  2. UseCase: Create the use cases in domain/usecase.
  3. Route: Add the feature routes in domain/route in the provider package and the link it in the Routing file, gated by the feature flag.

UI Specifics

Compose screens use a custom MVI Store pattern. Each screen has a Store ({Screen}Store) and a Screen composable ({Screen}Screen). Jobs inside a Store can be launched with an ID to cancel/replace prior work. The UI state uses kotlinx.collections.immutable (ImmutableList, ImmutableSet) to prevent accidental mutations.

Store<State, Action> (extends ViewModel) is the base class:

  • stateFlow: StateFlow<State> — single source of truth observed by the composable
  • send(action)reducer(state, action) — the only entry point for UI events
  • updateState { } — the only way to mutate state
  • launch(id, replace, context) { } — launches a coroutine tied to an ID
  • Flow<T>.observe(id) { } — lifecycle-aware collection that pauses after the UI unbinds and resumes when it reattaches

Translations

Translations are defined in the server file main/resources/static/translations.json. This file is fetched by the client TranslationService, which also caches the translations in the TranslationCache. Default translations are however bundled with the client in a mirror file commonMain/composeResources/files/translations.json in case of network unavailability. So adding new translations is as simple as adding new entries to the server file and optionally to the client file (recommended).

JSON Structure

The translations.json file represents a standard localization (i18n) dictionary. It maps string keys to their corresponding human-readable text for a specific language. Each object in the array defines a single translated string and consists of three properties:

  • languageIso: The ISO 639-1 two-letter language code identifying the language of the string (e.g., "en", "pt").
  • key: The unique identifier used within the codebase to request this specific piece of text (e.g., hello_world).
  • value: The actual localized text that will be displayed to the user on the UI (e.g., "Hello World").

So a multi-language translation file would look like:

[
  {
    "languageIso": "en",
    "key": "hello_world",
    "value": "Hello World"
  },
  {
    "languageIso": "pt",
    "key": "hello_world",
    "value": "Olá Mundo"
  },
  {
    "languageIso": "es",
    "key": "hello_world",
    "value": "Hola Mundo"
  }
]

Usage

The Translations.kt file offers helpers to collect the translation cache state, get and inject translations.

In the composables, call getTranslation(key, args) to get the localized string. The language ISO is handled automatically, only the key is needed; args is used to populate positional placeholders defined in the translation. Example:

{
  "languageIso": "en",
  "key": "translation_with_arguments",
  "value": "I have 2 arguments %1$s and %2$s"
}

Calling:

getTranslation("translation_with_arguments", "one", "two")

Returns:

I have 2 arguments one and two.

To inject translations in the cache and bypass the TranslationService, call InjectTranslations(translations). This is specially useful for composable previews.

Context Management

On Android, use the applicationContext provided via KInitializer to avoid explicit context injection.

Testing

The testing structure mirrors the source code to ensure 1:1 coverage. Code coverage is measured using Kover, configured independently per module but with the same exclusion rules everywhere: generated code, @Serializable classes, @Preview composables and anything annotated with @ExcludeFromTesting. This last annotation is preferred instead of explicit, less scalable declarations in Gradle. Kover is configured to verify a minimum of 90% code coverage.

Test classes, fake data and mocks live in the Shared Test module and are reused by every other module. Most modules implement the test classes with its specific TestCase which provides the harness for hermetic testing and provides:

  • An in-memory database and a mock Http engine.
  • runUnitTest { } — A function that sets up the test environment, resets all data, runs unit test then tears down.
  • runUITest { } — Same process as the unit test but for UI test. The setUI { } is used to render composables under AppTheme with mock lifecycle and navigation.
  • runServerTest { } — Same process as the unit test but with an environment that allows testing Http routes end-to-end.

Tests should be written in the common test directory, like commonTest, whenever possible.

Run tests using:

  • Shared: Run all tests using ./gradlew clean testSharedAndReport.
  • App Core: Run all tests using ./gradlew clean testAppCoreAndReport.
  • Client App: Run all tests using ./gradlew clean testClientAndReport.
  • Admin App: Run all tests using ./gradlew clean testAdminAndReport.
  • Server: Run all tests using ./gradlew clean testServerAndReport.

Platform Specific Implementations

These are the declarations that resolve to a distinct implementation per source set.

  • SqlDriver: Creates a SQLDelight async database driver.
  • HttpClientEngine: Creates a Ktor HTTP engine.
  • Network: Checks internet connectivity and exposes a Flow<Boolean> for real-time state changes.
  • File: IO operations on the local filesystem.
  • AppDataPath: Resolves the absolute path to the application's data directory.
  • Development: A boolean flag indicating debug mode.
  • Cryptography: Encrypt, decrypt, and hash string content.
  • PlatformLogger: A TelemetryEngine that writes to the native system console.
  • AudioPlayer: Creates a platform audio player for playlist control.
  • Locale: Reads the system language, formats UTC dates for the local timezone, and emits a Flow on locale changes.
  • Lifecycle: Composable that registers foreground/background callbacks.
  • Platform: Runtime metadata (OS name, version, brand, model).
  • Dispatcher: Provides the I/O CoroutineDispatcher.
  • VerticalScrollBar: Renders a styled scrollbar alongside a LazyList.
  • PlatformTestCase: Abstract base class for platform-specific test setup.

Deployment & Distribution

Bump versions with:

./gradlew bumpVersion [-PnewVersion=x.y.z] [-PnewCode=x] [-PnewAdminVersion=x.y.z] [-PnewServerVersion=x.y.z]

This updates every place the version lives:

  • build-logic/convention/src/main/kotlin/Shared.kt
    • appVersion, appVersionNumber, appAdminVersion, appServerVersion
  • iosApp/iosApp.xcodeproj/project.pbxproj
    • MARKETING_VERSION, CURRENT_PROJECT_VERSION

Standard compile commands for different platforms include:

  • iOS: Xcode archive
  • Android: ./gradlew clean :clientApp:bundleRelease
  • Mac: ./gradlew clean :clientApp:notarizeDmg --no-configuration-cache
  • Windows: ./gradlew clean :clientApp:packageMsi
  • Linux: ./gradlew clean :clientApp:packageDeb
  • Web: ./gradlew clean :clientApp:wasmJsBrowserDistribution
  • Server: ./gradlew :server:buildFatJar

Module-qualify the tasks (:clientApp:, :adminApp:) since there are modules that share the same unqualified task names.

Offline Engine

The offline engine is the mechanism that makes the app Offline First. It provides a persistent, fault-tolerant job queue backed by the database that continues executing background work when connectivity is interrupted, and resumes automatically when the device comes back online.

Job

A Job is the atomic unit of work. Every background operation is modeled as a Job before it touches the network.

Key properties:

  • entityType: EntityType — the domain entity the job operates on (e.g. TRANSLATION, SESSION)
  • entityUuid: Uuid? — the specific entity being mutated; null for global collection fetches
  • type: Type — the operation intent: GET, POST, or DELETE
  • conflictPolicy: ConflictPolicy — how to handle duplicate jobs; defaults to IGNORE for GET and APPEND for POST/DELETE
  • payload: String? — a JSON string carrying the execution parameters
  • state: State — the current lifecycle position: PENDINGRUNNINGCOMPLETE / FAILED / CANCELED
  • attempt: Int — a zero-indexed retry counter incremented on each failure

Conflict Policies

When a job is queued, the scheduler checks the ConflictPolicy before writing to disk:

  • APPEND — enqueue the new job as-is, alongside any pending duplicates (default for POST and DELETE)
  • REPLACE — cancel all pending duplicates, then enqueue the new job
  • IGNORE — skip if an identical job is already pending (default for GET)

Chain System

Jobs are grouped into logical chains identified by (userUuid, entityType, entityUuid). Within a chain, only the oldest pending job runs at a time, ensuring sequential mutation ordering for the same entity without blocking unrelated entities.

There are two job categories with distinct execution rules:

  • Targeted jobs (POST, DELETE, entityUuid != null) — Mutations on a specific entity. Jobs for Entity A execute sequentially while jobs for Entity B run in parallel on a separate chain.
  • Global jobs (GET, entityUuid == null) — Full collection syncs. A global job will not start if any pending or actively running targeted jobs exist for the same entityType. This guarantees local mutations are flushed to the server before the app pulls the latest remote state, preventing optimistic writes from being overwritten.

Network Awareness

The scheduler combines the pending jobs database stream with a Network stream and ClockService.trusted to gate execution. When the device goes offline, or its clock drifts from the server, all dispatching pauses. Dispatch resumes automatically once connectivity and clock trust are restored.

To run the client in a fully disconnected mode, swap out HttpClientEngine with NoOpHttpClientEngine (which returns 204 for every request) or set the feature flag http = false to make all outbound requests throw immediately without touching the network.

Clock Trust

On every network communication it is estimated the offset between the device clock and the server clock using an NTP-style round-trip calculation. If the measured offset exceeds clockDriftTolerance, the scheduler pauses all dispatch, since a device with an unsynchronized clock cannot be allowed to arbitrate modifiedAt based conflict resolution against other devices. Trust is restored automatically once a subsequent request reports an offset back within tolerance. The offline engine is still a trust based system, as this does not fully protect against malicious users trying to actively hack the engine.

Retry & Failure Handling

JobResult expresses the outcome of each execution:

  • Success / NoOp → job transitions to COMPLETE
  • Retry → attempt counter incremented; job reverts to PENDING if below schedulerMaxAttempts, otherwise transitions to FAILED
  • Error → terminal failure, job transitions to FAILED

Network exceptions are automatically converted to Retry rather than Error, so transient connectivity drops do not permanently fail jobs.

Zombie Recovery

If the OS kills the app while a job is RUNNING, that job would otherwise remain stuck forever. On every startup, all orphaned RUNNING jobs are reset back to PENDING before the scheduler loop begins.

FAQ

How do I set up the project from scratch?

Add a local.properties to the project root and fill in the required keys.

See Environment Setup for the full list. If you need push notifications, also see Push Notifications.


Why is dependency injection done manually instead of using a DI framework?

Kotlin Multiplatform support in popular DI frameworks adds complexity and limitations across all target platforms. The Dependency class in each module wires the full stack explicitly, which keeps the initialization path transparent and avoids generated code issues particularly on WASM.


Can I use the client without the bundled server?

Yes. Implement your own if you like. This is a starter kit. Or swap out HttpClientEngine with NoOpHttpClientEngine or just disable the flag ClientFlags.http to run the client in a fully disconnected mode. This is also how the test harness (TestCase) isolates client tests from the real network.


Should I share domain models directly with the server, or use separate DTOs?

The shared module is the single source of truth for domain models used by both sides. Models are annotated with @Serializable and sent over the wire directly; no separate DTO layer is needed. The client maps between domain models and the local database schema in each use case package, but the API contract itself is the domain model.


How do I handle platform-specific permissions?

Use the PermissionManager to check and request permissions.


Can I add a Splash and Error Screens?

Both are already in the ui/screen/ package as two blank placeholder screens.


How do I add offline support for a new operation?

Add the entity to EntityType, create the corresponding use cases in domain/usecase, then register a new Job in JobProvider that delegates to those use cases. The scheduler will persist and retry the job automatically when connectivity is restored.


How are data sync conflicts handled between the client and server?

The client writes locally first (optimistic write) and queues a background Job for the server sync. When the server response arrives, modifiedAt timestamps are compared, and the incoming version is saved only if it is equal to or newer than the local one (remote.modifiedAt >= local.modifiedAt). Batch syncs perform the same comparison in memory across a chunked local query, then commit only the outdated rows in a single atomic transaction. Scheduling conflicts are governed by Job.ConflictPolicy: typically, GET jobs use IGNORE (skip if an identical fetch is already pending) and POST/DELETE jobs use APPEND (queue normally, preserving the full operation history).

See Offline Engine for more details.


Can I drop down to native platform code if KMP lacks a feature?

That is part of the beauty of KMP. How much code should be written in Kotlin and platform native code is up to you!


Why does Desktop never use FCM for push notifications?

There is no official Firebase Cloud Messaging client SDK for a JVM desktop app. So DesktopPushProvider is a pure no-op, and Desktop relies entirely on its WebSocket/SSE connections staying open for the app's lifetime.

See Client Connection Lifecycle.


Why do Android and iOS close their WebSocket/SSE connections in the background instead of keeping them alive?

Two reasons. First, the OS suspends idle sockets shortly after backgrounding anyway (Android Doze, iOS background execution limits), so keeping the app's own connection "alive" while backgrounded is often an illusion. Second, FCM/APNs already exist specifically to wake up a backgrounded or killed app reliably and battery-efficiently. Reimplementing that with a self-managed socket is worse on every axis (battery, data usage, reliability) for no latency benefit a background push notification needs anyway. RegisterPushLifecycle closes the connection on background and reopens it on foreground on those two platforms only.


Why doesn't the server retry a failed WebSocket send by falling back to FCM for that same notification?

It could, but that would mean computing the FCM-exclusion list before attempting the WebSocket send, then re-sending via FCM only for the subset that failed, coordinating concurrent per-device sends before firing a single batched FCM call. The current design accepts a narrower gap instead: a WebSocket failure can miss that one notification until the next one re-checks connection presence. That tradeoff is acceptable because the actual fix for the underlying problem is the client behavior described above: a WebSocket only stays open in the first place when the OS says the app is genuinely reachable, so failures are rare and short-lived. If you ever widen the client lifecycle policy (e.g. keeping sockets alive longer in the background on some platform), revisit this tradeoff.


Why are WebSocket connection tickets and connection presence stored in the database instead of an in-memory map?

An in-memory map only exists on the instance that created it. On Cloud Run (or any horizontally-scaled deployment), a client's ticket request and its WebSocket upgrade can land on different instances, and a notification can be sent from an instance that holds none of the recipient's sockets. Persisting tickets (TicketTable) and presence (ConnectionTable) makes both instance-agnostic: any instance can redeem a ticket or correctly decide whether to skip FCM for a connected device, regardless of which instance actually holds the socket.

See Multi-Instance Scaling.


Why Postgres LISTEN/NOTIFY instead of Redis or a message broker for cross-instance signaling?

The project already requires Postgres for everything else, so LISTEN/NOTIFY covers the "wake up whichever instance holds this socket" need without a new piece of infrastructure to run, monitor, and pay for. It's a deliberately minimal fire-and-forget signal. Anything that must survive a missed delivery (the notification itself, connection presence) is already in the database, not riding on the signal. If you outgrow this (very high fan-out, cross-region, need for durable ordered delivery), that's when a real message broker earns its keep.


Do you use AI?

Autonomously in the code? No. As an assistant to write documentation? Sure. As an advanced search engine? Also yes.

To be clear, I am not against AI in code, but a supporter of a strict boundary between human-authored and AI-generated code, especially in the foundational layer. This prevents architectural drifts in my experience. Therefore, I strongly advocate for placing AI generated code into completely separate modules, to ensures a clear, undeniable boundary between the human-crafted and AI outputs.

In this project, the foundational code is human authored. Again, it does not mean that AI was not used as a helper to get to a solution or as a productivity multiplier. I recommend using AI to generate isolated, repeatable features, and their tests, provided they follow the project's established patterns, and are always human reviewed. Moreover, the design module is a great candidate for AI-assisted development as an isolated design system where all UI components live.


About

App bootstrapper

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages