Native Mobile Standards
Two independent native apps per product: Kotlin + Compose on Android, Swift + SwiftUI on iOS.
No cross-platform UI framework — no Flutter, no React Native, no MAUI. Escape hatch: a client funding three or more platforms with heavy shared business rules can justify Kotlin Multiplatform for the domain layer only, UI native on both. That needs an ADR and CTO sign-off.
The default stack for a new app
Section titled “The default stack for a new app”| Concern | Android | iOS |
|---|---|---|
| Language | Kotlin | Swift, Swift 6 language mode |
| UI | Jetpack Compose | SwiftUI |
| State | ViewModel + StateFlow<UiState> |
@Observable model |
| Async | Coroutines + Flow | async / await |
| DI | Hilt | Constructor injection from a composition root — no container |
| HTTP | Retrofit + OkHttp + kotlinx.serialization | URLSession + Codable |
| Dependencies | Version catalog (libs.versions.toml), Kotlin DSL |
SPM, exact pins |
iOS 17 is the hard floor — @Observable and the current concurrency model require it.
One architecture, two platforms
Section titled “One architecture, two platforms”- Unidirectional data flow. The view renders one immutable UI state and emits events. The ViewModel or model owns state.
- The repository seam is mandatory. All I/O goes through a repository — a view or ViewModel that touches Retrofit or
URLSessiondirectly fails review. - On a legacy app, the standard applies to new code. New screens are Compose / SwiftUI inside the existing app. You do not rewrite a working screen to hit a standard; you migrate it when it is being changed substantially anyway. A big-bang rewrite needs an ADR.
Enforced config
Section titled “Enforced config”| Platform | Gate | Where it lives |
|---|---|---|
| Android | detekt with the formatting ruleset (it embeds the ktlint rules — one tool, one config) | .editorconfig, config/detekt/detekt.yml |
| Android | Kotlin compiler warnings are errors | build.gradle.kts |
| iOS | SwiftLint --strict — a warning is an error |
.swiftlint.yml |
| iOS | Swift 6 language mode, strict concurrency | the Xcode project / Package.swift |
- Every mobile repo has a PR-triggered pipeline that runs lint and unit tests. A repo without a green PR pipeline is not a delivered repo.
abortOnError falseon Android lint is banned — it turns the gate off silently.- No suppression without a comment saying why.
- If a formatter is ever added on iOS it is
swift-format. One formatter, never two.
Signing, versioning & platform floors
Section titled “Signing, versioning & platform floors”Signing material is a secret. Keystores, .p12 certificates, provisioning profiles, and store API keys live in Azure DevOps Secure Files; their passwords in a Key Vault–backed variable group. Never in the repo, never in build.gradle, never in pipeline YAML. If you find one committed, follow the secrets rule.
- Debug builds use the local debug key. Only CI holds the release key.
- Release builds ship with R8 enabled, and upload the mapping file / dSYM to the crash reporter.
- Version is
major.minor.patch, identical on both platforms for a release.versionCode/CFBundleVersionis the pipeline build counter — monotonic, never derived from the marketing string. - The minimum supported version is enforced by the API, not the store. The backend refuses clients below a configured floor and returns an upgrade response. Store rollout is not something we control; this is.
- Floors: iOS supports the current release and the two before it. Android
minSdkcomes from Play install-base data with a floor of API 28, reviewed annually.targetSdkand the iOS SDK are always the latest — Google Play enforces a rolling annualtargetSdkdeadline. - Crash reporting is wired before the first client build. Push is FCM + APNs; add Huawei HMS only when the target market has real Huawei share, because it is a second provider to maintain.
Arabic-first and RTL
Section titled “Arabic-first and RTL”Arabic is a first-class language, not a translation pass. No hard-coded user-visible strings — Android res/values*/strings.xml, iOS .strings through generated accessors.
- Layout uses start / end, never left / right.
- RTL is set once at the app root, never per screen — a per-view opt-in guarantees a screen will miss it and render LTR in Arabic.
- Normalise Arabic-Indic digits to Latin before sending to the API.
- Every screen is reviewed in Arabic before it is called done.
Testing
Section titled “Testing”Android: JUnit + Turbine for ViewModel and repository logic, androidx.compose.ui.test for screen tests. iOS: Swift Testing for logic, XCUITest for the smoke suite. See Testing strategy for the coverage gate and the CI lanes.
See also: Examples · Testing · Pipelines & Environments.