Kotlin Multiplatform (KMP) Compose Multiplatform app targeting Android and iOS.
| Layer | Library |
|---|---|
| UI | Compose Multiplatform 1.11.1 |
| Navigation | Voyager 1.1.0-beta03 |
| Networking | Ktor 3.5.0 (OkHttp on Android, Darwin on iOS) |
| Backend | Supabase (supabase-kt 3.5.0, Postgrest) |
| Offline cache | SQLDelight 2.3.2 |
| DI | Koin 4.2.1 |
| Serialization | Kotlin Serialization |
| Language | Kotlin 2.3.20 |
| Tool | Version | Notes |
|---|---|---|
| JDK | 21+ | Required for Gradle and Kotlin compilation. Install via SDKMAN (sdk install java 21-tem) or Adoptium |
| Android Studio | Latest stable (Ladybug or newer) | Includes Android SDK, emulator, and Gradle tooling |
| Android SDK | API 36 (compileSdk/targetSdk), min API 26 | Install via Android Studio β SDK Manager |
| Xcode | 16+ | Required for iOS builds. Install from the Mac App Store |
| Xcode Command Line Tools | Latest | xcode-select --install |
- Kotlin Multiplatform Mobile plugin for Android Studio
git clone <repo-url>
cd conference-appCreate or verify local.properties in the project root:
sdk.dir=/Users/<your-username>/Library/Android/sdkAndroid Studio creates this automatically when you open the project.
Add your project's URL and publishable key to the same local.properties:
supabase.url=https://your-project-ref.supabase.co
supabase.publishableKey=sb_publishable_xxxxxxxxxxxxxxxxxxxxxxxxSee Supabase below for details.
- Android: Open the
conference-approot directory in Android Studio - iOS: Open
iosApp/iosApp.xcodeprojin Xcode (the shared KMP framework builds automatically)
conference-app/
βββ shared/ # KMP shared module
β βββ src/
β βββ commonMain/ # Cross-platform code (UI, navigation, data layer, DI)
β β βββ sqldelight/ # Local SQLite schema (offline cache)
β βββ commonTest/ # Mapper/local/repository/integration tests
β βββ androidMain/ # Android-specific implementations (OkHttp engine, SQLDelight driver)
β βββ iosMain/ # iOS-specific implementations (Darwin engine, SQLDelight driver)
β βββ iosTest/ # DI wiring test (needs the iOS platform module)
βββ androidApp/ # Android application module
β βββ src/main/
β βββ kotlin/.../MainActivity.kt
βββ iosApp/ # Native iOS app (Swift wrapper)
β βββ iosApp/
β βββ iOSApp.swift # App entry point
β βββ ContentView.swift # Hosts Compose UI via UIViewControllerRepresentable
βββ docs/ # DESIGN.md (design system), VENUE-MAP.md (floor-plan authoring)
βββ supabase/ # Migrations, RLS policies, import RPCs, seed data + schemas
βββ scripts/ # Node seed scripts (validate + import supabase/seed/*.json)
βββ gradle/libs.versions.toml # Version catalog
βββ Makefile # Build & run shortcuts
βββ build.gradle.kts # Root build configuration
make help # Show all available targets
make android-run # Build, install, and launch on Android emulator/device
make ios-run # Build and run on iOS Simulator
make build # Full compilation check
make clean # Clean all build outputs
make seed # Import supabase/seed/schedule.json
make seed-venue # Import supabase/seed/venue.json (run after `make seed`)
make rls-audit # Audit RLS + grants on the local Supabase DB
make format # Apply Kotlin formatting
make format-swift # Apply Swift formatting (macOS)
make check # Lint + tests + Android build
make ci # Everything CI runs, including the iOS half (macOS)See Quality checks for the full list.
# Build everything
./gradlew build
# Build Android app
./gradlew :androidApp:assembleDebug
# Install on connected Android device/emulator
./gradlew :androidApp:installDebug- Start an Android emulator from Android Studio (or connect a physical device with USB debugging enabled)
- Run:
Or use the Run button in Android Studio with the
make android-run
androidAppconfiguration.
- Open
iosApp/iosApp.xcodeprojin Xcode - Select a simulator (e.g. iPhone 17)
- Press Run (βR)
Or from the terminal:
make ios-runNote: iOS builds require a Mac with Xcode installed. The shared KMP framework is compiled as a static framework for
iosArm64andiosSimulatorArm64.
Formatting, tests and both platform builds run on every pull request
(.github/workflows/ci.yml) and are runnable locally from one command.
make check # any host: lint + JVM tests + Android build
make ci # macOS: the above, plus Swift lint, iOS tests, framework link| Target | Runs | Host |
|---|---|---|
make format |
spotlessApply β rewrites Kotlin to match ktlint |
any |
make format-swift |
swift-format --in-place over iosApp/ |
macOS |
make lint |
spotlessCheck β Kotlin formatting, no rewrite |
any |
make lint-swift |
swift-format lint --strict over iosApp/ |
macOS |
make test |
:shared:testAndroidHostTest |
any |
make test-ios |
:shared:iosSimulatorArm64Test (also covers src/iosTest) |
macOS |
make build-android |
:androidApp:assembleDebug |
any |
make build-ios |
Links the shared framework for simulator and device | macOS |
CI only ever checks. It does not run spotlessApply, does not run
swift-format --in-place, and never pushes a commit to your branch β an
unformatted file simply fails the PR. Run make format before pushing.
Kotlin formatting is ratcheted to origin/main: Spotless only looks at
files that differ from origin/main. So the check is green on a clean tree
without a repo-wide reformat commit, and a pull request only has to format the
files it actually touches. Format debt is paid down file by file as code is
naturally edited.
The ratchet works at file granularity. Changing one line in a file that has never been formatted means formatting that whole file β
make formatdoes it for you, but the diff will be larger than the change itself.
Tooling: Spotless driving
ktlint for Kotlin (configured in
build.gradle.kts and .editorconfig), and swift-format for Swift
(.swift-format). swift-format ships inside Xcode β xcrun finds it, there is
nothing to install.
Note that .editorconfig is cached by the Gradle daemon: after editing it, run
./gradlew --stop or the change is ignored.
Two jobs, in parallel:
| Job | Runner | Steps |
|---|---|---|
lint-test-android |
ubuntu-latest |
spotlessCheck β :shared:testAndroidHostTest β :androidApp:assembleDebug |
ios |
macos-latest |
swift-format lint --strict β :shared:iosSimulatorArm64Test β framework link (simulator + device) |
No secrets are required. local.properties is absent on CI, so
:shared:generateSupabaseConfig falls back to its placeholder values and
SupabaseIntegrationTest skips itself unless SUPABASE_URL and
SUPABASE_PUBLISHABLE_KEY are set in the environment.
The iOS job checks that the shared framework links; it does not run
xcodebuild against iosApp.xcodeproj.
The schedule (tracks, locations, speakers, events) and the venue map (venues, levels,
map features) live in Supabase Postgres and are cached locally on-device with
SQLDelight; the app always reads from that cache (ScheduleRepository.observeSchedule(),
VenueMapRepository.observeVenueMap()) and only hits the network to refresh it.
| Path | Purpose |
|---|---|
supabase/config.toml |
CLI project config, committed alongside migrations |
supabase/migrations/ |
Schema, RLS policies, grants, and the import_schedule() / import_venue() / get_venue_map() RPCs |
supabase/checks/rls-audit.sql |
Read-only audit of the RLS/grants posture (see Row Level Security) |
supabase/seed/schedule.schema.json |
JSON Schema for the schedule seed document |
supabase/seed/schedule.json |
Editable programme data (slug-keyed, no UUIDs) |
supabase/seed/venue.schema.json |
JSON Schema for the venue map seed document |
supabase/seed/venue.json |
Editable floor-plan geometry (slug-keyed, no UUIDs) |
scripts/seed-supabase.mjs |
Validates schedule.json and imports it via import_schedule() |
scripts/seed-venue.mjs |
Validates venue.json and imports it via import_venue() |
scripts/venue-from-geojson.mjs |
Folds a QGIS GeoJSON export into venue.json |
SupabaseConfig.kt is generated at build time by the :shared:generateSupabaseConfig
Gradle task, not hand-edited. It reads from local.properties (already gitignored, same
file as sdk.dir), falling back to a placeholder if the keys are missing:
supabase.url=https://your-project-ref.supabase.co
supabase.publishableKey=sb_publishable_xxxxxxxxxxxxxxxxxxxxxxxxThe publishable key is safe to have on disk; access is controlled by RLS, not by keeping it secret.
# from the repo root, with the Supabase CLI installed
supabase link --project-ref <your-project-ref>
supabase db push # applies supabase/migrations/*.sqlexport SUPABASE_URL=https://<your-project-ref>.supabase.co
export SUPABASE_SERVICE_ROLE_KEY=<service-role-key> # bypasses RLS β keep out of git
make seedmake seed validates supabase/seed/schedule.json against the JSON Schema and checks
its track/location/speaker cross-references locally β before any network call. It then
calls the transactional import_schedule() RPC, which upserts by slug and prunes rows no
longer present in the file, so the JSON is the full source of truth for each run.
Note:
SUPABASE_SERVICE_ROLE_KEYbypasses Row Level Security entirely. Never commit it, put it in Kotlin source, or add it toschedule.json.
make seed-venue # same two environment variablesRun it after make seed: map features reference locations rows by slug, and the
script fails on a slug that schedule.json does not define.
make seed-venue validates supabase/seed/venue.json against its JSON Schema, checks
that every polygon ring closes and encloses an area, and resolves the location
cross-references β all before any network call. It then calls the transactional
import_venue() RPC, whose reconciliation is scoped to the venue in the payload.
The floor plan itself is traced in QGIS and folded into venue.json by
scripts/venue-from-geojson.mjs. The whole authoring workflow β coordinate system,
QGIS setup, attribute fields, export β is in docs/VENUE-MAP.md.
The app reads through the anon role, so every table in public is exposed to
the internet by default and two independent layers have to agree before a row
comes back: the SQL grant (what the role may do at all) and the RLS
policy (which rows). RLS restricts, it never grants.
20260815000200_lock_down_grants.sql retracts Supabase's stock
alter default privileges β¦ grant all on tables to anon, authenticated, so
nothing is handed out automatically any more. That makes the rule for new
objects explicit:
- A new table needs
alter table β¦ enable row level security, aselectpolicy, and an explicitgrant select β¦ to anon, authenticated. Without the grant the client getspermission denied; without RLS + policy it gets nothing back (deny-all). Failing loudly in that direction is the point β the old defaults failed the other way, with a silently writable table. - A new function needs an explicit
revoke all on function β¦ from publicin the migration that creates it, followed by agrant executeto whoever should really call it. This one is not covered by the default-privilege lockdown: Postgres grantsEXECUTEon every new function toPUBLIC, andalter default privilegesis merged additively onto that built-in default rather than overriding it, so the grant cannot be retracted ahead of time. A function added without therevokeis callable byanonfrom the moment it exists, and nothing complains. Import RPCs are the model to copy:security definer,set search_path = public, revoked frompublic, granted only toservice_role.
That second case is silent, which is exactly why the audit exists.
To check the invariants rather than assume them:
supabase start # the audit runs against the local stack
make rls-auditIt prints eight sections. Three must come back empty: tables with RLS
disabled, tables with RLS but no policy, and grants to anon/authenticated
beyond SELECT. Section 6 β functions anon can execute β is the one to read
rather than count: it should list get_venue_map(text) and nothing else, and
anything marked PUBLIC (implicit) is a function that skipped its revoke.
Section 7 flags leftover default ACLs; supabase_admin rows there are expected
(they apply only to objects that role creates, and our migrations run as
postgres), a postgres row is a regression. Run it against a hosted project
too, once one exists:
psql "$DB_URL" -f supabase/checks/rls-audit.sqlmake rls-audit needs psql (brew install libpq; Homebrew keeps it off
PATH, and the Makefile finds it there anyway).
supabase start # from the repo root; applies supabase/migrations/*.sql automaticallyPrints an API URL and anon/service_role keys. Put the anon key in local.properties
as supabase.publishableKey, and point supabase.url at the device's own loopback:
supabase.url=http://127.0.0.1:54321127.0.0.1 means the device, not your Mac, so make android-run runs
adb reverse tcp:54321 tcp:54321 (target adb-reverse) to forward it back to the host.
This works on physical devices as well as emulators. The forward is dropped on unplug or
reboot, so it is re-established on every launch; re-run make adb-reverse by hand if you
attach a device without rebuilding. Emulator-only alternative: http://10.0.2.2:54321,
the emulator's built-in alias for the host loopback, which needs no forwarding.
Android blocks plain HTTP by default; androidApp ships a network security config
(res/xml/network_security_config.xml) that allows cleartext to
127.0.0.1/10.0.2.2/localhost only, so this works without weakening the release
build's HTTPS-only policy elsewhere.
Seeding runs on the host, straight to 127.0.0.1 with no forwarding
involved, and needs the service_role key printed by supabase start rather than the
anon key β service_role bypasses RLS, so it must never end up in local.properties
or the app:
SUPABASE_URL=http://127.0.0.1:54321 SUPABASE_SERVICE_ROLE_KEY=<service_role_key> make seedScheduleRepository treats the on-device SQLDelight database as the source of truth.
refresh() fetches from Supabase and replaces the cache in one transaction, but
observeSchedule() always emits from the cache regardless of whether that refresh
succeeded β so the UI keeps showing the last known programme when offline.
VenueMapRepository works the same way against the same database, with a 24-hour TTL
instead of 15 minutes: a talk can move an hour before it starts, a wall cannot. The map
is cached as the raw get_venue_map() document in a single row and parsed on read.
| File | Purpose |
|---|---|
gradle/libs.versions.toml |
Centralized dependency versions |
gradle.properties |
JVM args, Kotlin style, AndroidX settings |
local.properties |
Local Android SDK path (gitignored) |
local.properties (supabase.*) |
Supabase URL + publishable key (see Supabase) |
shared/build.gradle.kts |
KMP targets, shared dependencies |
androidApp/build.gradle.kts |
Android app config (SDK versions, app ID) |
.editorconfig |
ktlint code style and disabled rules (see Quality checks) |
.swift-format |
swift-format config for iosApp/ |
.github/workflows/ci.yml |
Lint, test and build checks run on every PR |
- Ensure JDK 21+ is installed and configured:
java -version - Confirm Android SDK API 36 is installed via Android Studio β SDK Manager
- Run
./gradlew --stopto kill stale Gradle daemons, then retry
- Ensure Xcode command line tools are set:
sudo xcode-select -s /Applications/Xcode.app - Clean the KMP framework:
./gradlew :shared:cleanIosSimulatorArm64Binaries - In Xcode: Product β Clean Build Folder (β§βK)
- Accept Xcode license:
sudo xcodebuild -license accept - Set
JAVA_HOME:export JAVA_HOME=$(/usr/libexec/java_home -v 21)