The official Android app for droidcon Kenya, the Android developer conference held in Nairobi. It carries the schedule, speakers, sponsors, organisers and event feed, works offline once it has synced, and lets attendees bookmark the sessions they plan to attend.
The app is also the community's shared teaching codebase: a current, opinionated,
multi-module Android project that people learn from and contribute to. Most of the
decisions in here are written down rather than assumed — see docs/ and
AGENTS.md.
- Designs
- Running the project
- Module layout
- Architecture
- Tech stack
- Quality gates
- Testing
- Adding a dependency
- Contributing
- Contributors
- Licence
This is the link to the app designs:
Clone it and build. There is no setup step.
git clone https://github.com/droidconKE/droidconKeKotlin.git
cd droidconKeKotlin
./gradlew assembleDebugOr open the folder in Android Studio and press Run.
You do not need to install or configure a JDK. gradle/gradle-daemon-jvm.properties pins the
daemon to Java 17 and the foojay resolver in
settings.gradle.kts downloads it if your machine hasn't got one. The Android SDK components,
the Gradle distribution, and the emulators the instrumentation tests run on are all provisioned
by the build too.
The debug build is signed with the checked-in keystore/dckedebug.keystore, so a fresh clone
installs over an existing build and keeps the Firebase debug SHA-1 stable. That is deliberate —
it is a shared debug key, not a secret, and the release key is not in this repo.
| Module | What lives there |
|---|---|
app |
Application, MainActivity, navigation, notifications, DI root |
core:model |
Pure Kotlin data classes. A JVM module — no Android on its classpath |
core:common |
Dispatcher qualifiers and other cross-cutting plumbing |
core:designsystem |
chai — colours, typography, shapes, shared components |
core:ui |
Presentation models, shared composables, navigation keys, resources |
core:domain |
Repository interfaces and sync contracts |
core:data |
Repository implementations, the sync worker, mappers |
core:database |
Room database, DAOs, entities |
core:network |
Ktor client, DTOs, Remote Config |
core:screenshot |
Roborazzi harness. Test-only |
core:testing |
Shared test doubles. Test-only |
benchmarks |
Macrobenchmark and baseline profile generation. Not shipped |
feature:* |
about auth feed home sessions speakers — screens, view models, tests, goldens |
build-logic |
Convention plugins — every module's build config comes from here |
graph TD
app[":app"]
feature["<b>:feature:*</b><br/>about · auth · feed<br/>home · sessions · speakers"]
ui[":core:ui"]
ds[":core:designsystem"]
domain[":core:domain"]
model[":core:model"]
data[":core:data"]
database[":core:database"]
network[":core:network"]
app --> feature
app --> data
app --> database
app --> network
feature --> ui
ui --> ds
ui --> domain
domain --> model
data --> domain
data --> database
data --> network
classDef pure fill:#0b7285,stroke:#0b7285,color:#ffffff;
classDef uic fill:#5f3dc4,stroke:#5f3dc4,color:#ffffff;
classDef io fill:#2b8a3e,stroke:#2b8a3e,color:#ffffff;
class domain,model pure;
class app,feature,ui,ds uic;
class data,database,network io;
The dependency rules: core:model depends on nothing and has no Android on its
classpath, which the build enforces — that is what would make a move to Kotlin Multiplatform a
port rather than a rewrite. core:data depends on core:domain and never the reverse. A
feature module never depends on another feature module; anything two features need belongs in
core:ui, and cross-feature navigation goes through the NavKeys it owns. app is the only
module that may depend on every feature, because the composition root knows about all of them
by definition.
Note that core:database and core:network do not depend on core:domain either. They own
their own DTOs and Room entities and know nothing about the domain model; core:data is the
only module that sees both sides, and the mappers there are the seam. That is why swapping the
API representation of a session does not reach the UI.
A new module applies the convention plugins rather than copying a build.gradle.kts:
plugins {
alias(libs.plugins.droidconke.android.library)
alias(libs.plugins.droidconke.android.hilt)
}Offline-first. The UI never waits on the network: screens read from Room, and a WorkManager job refreshes Room from the API in the background. A cold start with no connection still shows the last synced schedule.
sequenceDiagram
autonumber
participant UI as Compose screen
participant VM as ViewModel
participant Repo as Repository<br/>(:core:data)
participant Room as Room<br/>(:core:database)
participant Ktor as Ktor<br/>(:core:network)
participant Work as SyncDataWorker
UI->>VM: collect uiState
VM->>Repo: observe sessions
Repo->>Room: query, returns a Flow
Room-->>UI: cached data, immediately
Note over Work: periodic + on app start
Work->>Repo: sync() for sessions, speakers,<br/>sponsors, organisers, feed
Repo->>Ktor: fetch
Ktor-->>Repo: DTOs
Repo->>Room: replace
Room-->>UI: Flow re-emits, UI updates
The five repositories sync concurrently and the job only reports success when all of them succeed, so a partial refresh is retried rather than treated as done.
Fuller treatment, including the navigation model and the Room schema: docs/architecture.md.
| Concern | Choice |
|---|---|
| Language | Kotlin 2.4, coroutines and Flow |
| UI | Jetpack Compose, Material 3, chai design system |
| Adaptive | material3-adaptive — bar / rail / drawer and two panes, by window size |
| Navigation | Navigation 3 — @Serializable NavKeys, no route strings |
| DI | Hilt with KSP |
| Networking | Ktor 3 with kotlinx.serialization |
| Persistence | Room 2.8, DataStore |
| Background work | WorkManager |
| Images | Coil |
| Firebase | Crashlytics, Remote Config, Messaging, Performance |
| Logging | Timber |
| Tests | JUnit4, Robolectric, MockK, Turbine, Compose UI test |
Navigation 3 is not Navigation 2 renamed. Destinations are @Serializable keys implementing
NavKey; there is no NavHost and there are no route strings. See
presentation/src/main/java/com/android254/presentation/common/navigation/.
Everything below runs on every pull request. Run it locally before pushing and CI holds no surprises.
./gradlew spotlessApply ktlintFormat # format first — the checks are strict
./gradlew spotlessCheck ktlintCheck detekt # style and static analysis
./gradlew lint # Android Lint + Slack's Compose rules
./gradlew stabilityCheck # Compose recomposition regressions
./gradlew testDebugUnitTest # JVM + Robolectric| Tool | Catches |
|---|---|
| spotless | Formatting and the Apache licence header on every file |
| ktlint | Kotlin style, official code style |
| detekt | Complexity, code smells, TODO left in comments |
| Android Lint | Platform, resource, manifest and API-level correctness |
| compose-lints | Compose API shape, state and stability mistakes |
| compose-stability-analyzer | Composables that quietly stop being skippable |
There is no lint baseline and no ktlint baseline. Severity decisions live in
config/lint/lint.xml, one rule per line with the reason next to it,
so every suppression shows up in a diff. What each tool is set to, why, and the debt still
being burned down: docs/static-analysis.md.
./gradlew testDebugUnitTest # all unit tests
./gradlew :feature:sessions:testDebugUnitTest --tests "*SessionsFilterStateTest*"Instrumentation tests run on Gradle Managed Devices, so there is no emulator to create or start — Gradle provisions and tears them down:
./gradlew :core:data:supportedApiLevelsGroupDebugAndroidTest # api30 + api34Coverage is measured with JaCoCo on debug variants and reported to Codecov.
All dependencies are declared in
gradle/libs.versions.toml, a Gradle
version catalog.
One place to look, one place to bump, and changing a version does not invalidate the
compilation of every module.
Add the version under [versions], then the library under [libraries]:
[versions]
splash = "1.2.0"
[libraries]
androidx-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splash" }Then use it as implementation(libs.androidx.splashscreen) — Gradle normalises -, _ and
. into . for the accessor. Check whether it belongs in an existing [bundles] entry
before adding it module by module.
To see what is out of date:
./gradlew dependencyUpdatesA composable that calls hiltViewModel() cannot be previewed. Split it: keep a composable
that takes the ViewModel and immediately delegates to a second one that takes plain state and
callbacks, then preview the second. The
Compose tooling docs
cover the pattern.
Contributions are welcome, and this repository is deliberately a good place to make a first
one. CONTRIBUTING.md covers the process — issues, forks, PR
expectations. AGENTS.md covers the things that have already cost this codebase
a bug and are easy to reintroduce; it is worth ten minutes before your first PR.
We would endlessly like to thank the following contributors
Copyright 2026 droidcon Kenya
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.