Skip to content

Repository files navigation

TBC Conference App

Kotlin Multiplatform (KMP) Compose Multiplatform app targeting Android and iOS.

Tech Stack

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

Prerequisites

Required

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

Recommended

Setup

1. Clone the repository

git clone <repo-url>
cd conference-app

2. Configure Android SDK path

Create or verify local.properties in the project root:

sdk.dir=/Users/<your-username>/Library/Android/sdk

Android Studio creates this automatically when you open the project.

3. Configure Supabase

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_xxxxxxxxxxxxxxxxxxxxxxxx

See Supabase below for details.

4. Open the project

  • Android: Open the conference-app root directory in Android Studio
  • iOS: Open iosApp/iosApp.xcodeproj in Xcode (the shared KMP framework builds automatically)

Project Structure

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

Build & Run

Using the Makefile

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.

Using Gradle directly

# Build everything
./gradlew build

# Build Android app
./gradlew :androidApp:assembleDebug

# Install on connected Android device/emulator
./gradlew :androidApp:installDebug

Running on Android

  1. Start an Android emulator from Android Studio (or connect a physical device with USB debugging enabled)
  2. Run:
    make android-run
    Or use the Run button in Android Studio with the androidApp configuration.

Running on iOS

  1. Open iosApp/iosApp.xcodeproj in Xcode
  2. Select a simulator (e.g. iPhone 17)
  3. Press Run (⌘R)

Or from the terminal:

make ios-run

Note: iOS builds require a Mac with Xcode installed. The shared KMP framework is compiled as a static framework for iosArm64 and iosSimulatorArm64.

Quality checks

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

Formatting is applied by you, never by CI

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 format does 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.

What CI runs

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.

Supabase

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.

Project layout

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

Configuring the app

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_xxxxxxxxxxxxxxxxxxxxxxxx

The publishable key is safe to have on disk; access is controlled by RLS, not by keeping it secret.

Linking a hosted project

# from the repo root, with the Supabase CLI installed
supabase link --project-ref <your-project-ref>
supabase db push   # applies supabase/migrations/*.sql

Seeding data

export SUPABASE_URL=https://<your-project-ref>.supabase.co
export SUPABASE_SERVICE_ROLE_KEY=<service-role-key>   # bypasses RLS β€” keep out of git
make seed

make 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_KEY bypasses Row Level Security entirely. Never commit it, put it in Kotlin source, or add it to schedule.json.

Seeding the venue map

make seed-venue    # same two environment variables

Run 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.

Row Level Security and grants

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, a select policy, and an explicit grant select … to anon, authenticated. Without the grant the client gets permission 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 public in the migration that creates it, followed by a grant execute to whoever should really call it. This one is not covered by the default-privilege lockdown: Postgres grants EXECUTE on every new function to PUBLIC, and alter default privileges is merged additively onto that built-in default rather than overriding it, so the grant cannot be retracted ahead of time. A function added without the revoke is callable by anon from the moment it exists, and nothing complains. Import RPCs are the model to copy: security definer, set search_path = public, revoked from public, granted only to service_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-audit

It 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.sql

make rls-audit needs psql (brew install libpq; Homebrew keeps it off PATH, and the Makefile finds it there anyway).

Running against a local Supabase (Docker)

supabase start   # from the repo root; applies supabase/migrations/*.sql automatically

Prints 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:54321

127.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 seed

Offline caching

ScheduleRepository 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.

Configuration

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

Troubleshooting

Gradle sync fails

  • Ensure JDK 21+ is installed and configured: java -version
  • Confirm Android SDK API 36 is installed via Android Studio β†’ SDK Manager
  • Run ./gradlew --stop to kill stale Gradle daemons, then retry

iOS build fails

  • 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)

General environment issues

  • Accept Xcode license: sudo xcodebuild -license accept
  • Set JAVA_HOME: export JAVA_HOME=$(/usr/libexec/java_home -v 21)

About

Application for TBC conference attendees

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages