Appendix — Android / Kotlin
Stack appendix for the Sigma Engineering Standards. Covers Android applications and Kotlin code targeting the Android runtime. Most rules also apply to JVM-only Kotlin; sections marked (Android) are platform-specific. This is developer best practice first — how a Sigma engineer writes idiomatic Kotlin by hand. It is also the bar an AI agent writing Kotlin in our repos is held to: match these idioms, and surface any deviation (see §8 of the standard, AI Agent Rules of Engagement).
1. Tooling Baseline
Required in CI for every Android/Kotlin repo:
- ktlint — formatting and basic style. No diff allowed.
- detekt — static analysis with our shared ruleset (
detekt.ymlin the org config repo). Threshold: zero issues aterrorseverity. - Android Lint with
lintOptions { abortOnError true }for release variants. (Android) - Kotlin compiler warnings as errors —
allWarningsAsErrors = true. - Unit tests — JUnit5 + MockK only when a hand-written fake won't do.
- Instrumented / Compose tests — Espresso,
compose-ui-testfor screens that justify them. - Dependency audit —
gradle dependencyCheck(OWASP) or equivalent; vulnerability gate athigh. - R8 / ProGuard rules reviewed; release builds tested in their minified form. (Android)
gradle.lockfile committed for reproducible builds.
2. Kotlin Language Conventions
valovervarby default.varrequires a reason.- No
!!outside platform-type boundaries (Java/C interop). When it does appear, an inline comment justifies it. - No
lateinitin domain types — prefer constructor injection.lateinitis acceptable for Android-injected fields (Activities/Fragments where the framework owns construction). (Android) requireNotNull/checkNotNull/require/checkat trust boundaries — these are our defensive assertions and they produce clear failures.- Sealed classes / interfaces for closed sets of states.
whenover them is exhaustive — compiler-checked. - Data classes for value types. Avoid
equals/hashCodeby hand. - Inline value classes for IDs and unit-typed primitives (
UserId(value: Long)) — these are free at runtime and prevent the "wrong-id" bug. - No reflection in security-sensitive paths or hot paths.
- No
Any?in domain APIs. Use sealed hierarchies or specific generics.
3. Structured Concurrency (Coroutines)
Coroutines are mandatory for asynchronous work. Callbacks are legacy; convert when touching adjacent code.
Hard rules:
- Every coroutine has a defined
CoroutineScopetied to a lifecycle:viewModelScope,lifecycleScope, or a custom scope with an explicitJoband cancellation point. (Android) GlobalScopeis forbidden in application code. (Library code may justify it, with a tracked review.)- Honour cancellation. Don't catch
CancellationExceptionwithout rethrowing.try { ... } catch (e: Exception) { }swallows cancellation — use specific exceptions orcatch (e: CancellationException) { throw e }first. - Dispatchers explicit.
withContext(Dispatchers.IO)for blocking I/O;Dispatchers.Defaultfor CPU;Dispatchers.Mainonly for UI. InjectCoroutineDispatcherinto classes that do work so tests can substituteUnconfinedTestDispatcher. withTimeout(...)orwithTimeoutOrNull(...)on any operation that can hang.- Flow for streams of values.
StateFlowfor current UI state;SharedFlowfor one-shot events (configure replay/buffer explicitly). - No
runBlockingoutsidemain()and tests. Ever.
class UserViewModel(
private val repo: UserRepository,
private val io: CoroutineDispatcher = Dispatchers.IO,
) : ViewModel() {
private val _state = MutableStateFlow<UserState>(UserState.Loading)
val state: StateFlow<UserState> = _state.asStateFlow()
fun load(id: UserId) {
viewModelScope.launch {
_state.value = withContext(io) {
withTimeout(5.seconds) {
when (val r = repo.get(id)) {
is Result.Ok -> UserState.Loaded(r.value)
is Result.Err -> UserState.Error(r.error)
}
}
}
}
}
}
4. Errors as Values
For business logic, prefer a sealed Result-style type over thrown exceptions. Throws are reserved for genuinely exceptional conditions (programming errors, unrecoverable framework callbacks).
sealed interface Outcome<out T, out E> {
data class Ok<T>(val value: T): Outcome<T, Nothing>
data class Err<E>(val error: E): Outcome<Nothing, E>
}
sealed interface UserError {
data object NotFound : UserError
data class Network(val cause: Throwable) : UserError
data class Validation(val field: String, val reason: String) : UserError
}
(Kotlin's own kotlin.Result is fine for simple cases but its Throwable constraint is often the wrong shape for domain errors.)
At UI boundaries, map errors to displayable states. Never let a coroutine crash propagate to the UI without being caught and transformed.
5. State Management & Architecture
- MVVM or MVI, not MVP, not raw Activities/Fragments doing work.
- Single source of truth per state — a
StateFlowin a ViewModel or a Store. - UI state hoisted out of composables (Jetpack Compose) or out of Views — composables/views are presentational.
Configuration changessurvive: state in ViewModels, not in Views. (Android)- Navigation via Jetpack Navigation Compose or equivalent — typed routes preferred. (Android)
- No business logic in
Activity/Fragment/Composable. They orchestrate; they do not compute.
6. Jetpack Compose & UI
Compose is the default for new UI. The §5 architecture rules still hold — state hoisted, no business logic in a @Composable; what follows are the Compose-specific mechanics on top.
@Composable
fun UserScreen(viewModel: UserViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
UserContent(state, onRetry = viewModel::retry) // stateful wrapper
}
@Composable
private fun UserContent(state: UserState, onRetry: () -> Unit) {
// pure and stateless: UI as a function of `state`, no logic here
}
Composition is pure.
- No side effects in the composable body. A
@Composableruns often, in any order, on any thread, and may be abandoned mid-flight. Don't launch coroutines, mutate shared state, or do I/O directly in it. - Effects go through the effect APIs, keyed correctly:
LaunchedEffect(key)— coroutine work tied to composition; cancels and restarts whenkeychanges. Key it on what should restart it, notUnitunless you mean "once."DisposableEffect(key)— register/unregister pairs (listeners, callbacks); clean up inonDispose.rememberCoroutineScope()— to launch from a callback (a click), never from composition.rememberUpdatedState— read the latest value inside a long-lived effect without restarting it.
State.
remembersurvives recomposition;rememberSaveablesurvives configuration change and process death. Hoist anything a caller or ViewModel owns (§5).derivedStateOffor state computed from other state — don't recompute it in the body.collectAsStateWithLifecycle(), notcollectAsState(), so flow collection stops when the UI isn't visible. (Android)
Recomposition is the performance model.
- Keep
@Composableparameters stable. Unstable types — mutable collections, unstable lambdas, classes the compiler can't prove stable — defeat skipping. Reach for@Immutable/@Stabledeliberately, persistent collections (kotlinx.collections.immutable), and stable lambda references. key(...)items in lazy lists so reordering doesn't re-key everything below.- Defer state reads to the latest point — lambda-based modifiers (
Modifier.offset { ... }) read in layout, not composition. - Measure recomposition counts with the Layout Inspector and the Compose compiler's stability report before optimising; don't guess.
Previews and tests.
@Previewevery visually-meaningful component — light/dark and key states. They're documentation and a fast feedback loop.compose-ui-testfor screens with non-trivial state (§11); Paparazzi / Roborazzi for screenshot-stable components.
7. Background Work (Android)
WorkManagerfor deferrable, guaranteed-to-execute work (sync, uploads, periodic tasks).Service/Foreground Serviceonly when the user-visible activity model demands it (audio, navigation, fitness). Foreground service types declared in the manifest with the appropriate permission.- Never raw threads or
AsyncTask. Coroutines for in-process; WorkManager for cross-process. - JobScheduler / AlarmManager only for exact alarms with clear justification (calendar, medical reminders).
8. Security (Android)
Manifest hygiene
android:exporteddeclared explicitly on every component (required from API 31+).- No exported
Activity/Service/Receiverwithout an authentication or signature-level permission. android:allowBackup="false"or explicitandroid:fullBackupContentrules excluding sensitive directories.android:usesCleartextTraffic="false"for production; HTTP only via explicit Network Security Config exemption in dev builds.
Network Security Config
- TLS pinning for production builds (release-only NSC).
- Cleartext blocked in production.
- Trust anchors limited to system CAs — no user-added CAs in release variants.
Secrets & data at rest
- Small secrets:
EncryptedSharedPreferences(theandroidx.security:security-cryptolibrary) was deprecated in April 2025 — migrate off it. Use Tink for encryption (with a key held in the Keystore) and DataStore for persistence; don't reach for ESP in new code. Keystore(hardware-backed where available) for crypto keys.- Room with SQLCipher when a local DB holds sensitive data.
- Never log full tokens, PII, or payment data. Wrap
Loggerto enforce redaction.
Intent handling
- Validate every extra read from an intent. Trust nothing. Even your own app can be called by another caller via implicit intent if you forget to lock it down.
- Explicit intents for in-app navigation; implicit intents only when calling out, with
Intent.ACTION_*and a chooser. - App Links verified via Digital Asset Links for deep linking.
- Pending intents must specify
FLAG_IMMUTABLE(orFLAG_MUTABLEwith documented reason).
Other
- Disable backups for sensitive data via
dataExtractionRules(API 31+). - WebView:
setJavaScriptEnabledonly if needed,setAllowFileAccess(false),setAllowContentAccess(false), neveraddJavascriptInterfacewith untrusted content. - Don't expose
ContentProviderwithout permission. - Tap-jacking:
filterTouchesWhenObscuredfor sensitive actions (auth, payment).
9. Dependencies
Android force-feeds a baseline (AndroidX, Jetpack, Compose, Kotlin stdlib). Beyond that:
- AndroidX libraries preferred over equivalents from Google's legacy support library or third parties.
- Coroutines, Flow, Serialization as default async / data tooling.
- Network — OkHttp + Retrofit + KotlinX Serialization is the standard stack. Ktor on JVM-only / multiplatform.
- DI — Hilt for Android. Manual constructor injection acceptable for small modules and library code.
- Image loading — Coil (Kotlin-first, coroutines-native) preferred over Glide.
- Database — Room. Avoid raw SQLite outside Room unless justified.
- Anything else, follow main standard §6 criteria.
Lockfile (gradle.lockfile) committed. Renovate / Dependabot configured.
10. Build & Variants
- Build variants declared in
build.gradle(.kts)per flavour (dev / staging / prod / per-client). Each has its ownapplicationIdSuffix, signing config, network config, and obfuscation rules. - Signing keys never in the repo. Stored in the secret manager; injected by CI.
- Release builds tested — including the minified, obfuscated form. R8 rules adjusted with care; don't
-keep class **to silence a problem. - Crash reporters (Firebase Crashlytics or equivalent) wired up with redaction.
- Version code monotonically increases. Version name follows SemVer or a documented scheme.
11. Testing
- Unit tests for ViewModels, repositories, and pure-Kotlin logic —
kotlinx-coroutines-testfor time control. - Hand-written fakes by default (e.g.
FakeUserRepository). MockK / Mockito only when a fake is genuinely impractical. - Instrumented tests for what only the device can verify (DB schema, file I/O, hardware integrations).
- Compose tests for screens with non-trivial state.
- Screenshot tests for visually-stable components (Paparazzi / Roborazzi).
- LeakCanary in debug builds — leak detection is part of CI's smoke tests for any non-trivial change. (Android)
12. Anti-Pattern Quick List
GlobalScope.launch { ... }anywhere in app code.runBlockingoutsidemain/ tests.!!on framework-returned values without explicit null-check upstream.- Empty
catch (e: Exception) {}. Thread { }/AsyncTask— both legacy.findViewByIdin new code — use View Binding or Compose.- Subclassing
Applicationfor global mutable state. - Storing references to
Context/Activityin singletons or long-lived objects (leak risk). SharedPreferencesfor secrets — encrypt via Tink with a Keystore-held key (EncryptedSharedPreferencesis deprecated as of April 2025).- Exported components without
android:permission.
13. The Checklist (PR-time)
- [ ]
ktlint,detekt, Android Lint,gradle test, instrumented tests (where applicable) all green - [ ] No new
!!,lateinit(in domain),GlobalScope, or empty catches - [ ] Coroutines scoped to a lifecycle; cancellation honoured
- [ ] Errors handled as values or transformed to UI states; no uncaught crashes from background work
- [ ] Manifest changes reviewed for
exported,permission, deep links - [ ] No secrets in code or resources
- [ ] R8 rules updated if APIs added that need keeping; release build verified
- [ ] No new dependency, or justified in PR description against §9
References
Authoritative references for the Android / Kotlin stack:
- Kotlin documentation.
- Coroutines guide.
- Kotlin coding conventions.
- Guide to app architecture.
- Jetpack Compose documentation.
Sigma Android/Kotlin Appendix — v1.2 · pairs with main standard v1.3