Getting Started
Requirements, running the app, the checks and a release build of a generated project.
Requirements
The generated README.md lists the exact versions: JDK, Kotlin, Compose
Multiplatform, AGP, Gradle, Android SDK levels and the iOS minimum. Gradle runs on JDK 21 and
downloads it when it is missing. iOS needs Xcode 16 or newer, and the iOS targets are arm64
only (an Apple Silicon Mac or a device).
Run
Android: ./gradlew :androidApp:installDebug. iOS: open
iosApp/iosApp.xcodeproj in Xcode and run. On a device, set TEAM_ID in
Config.xcconfig; it is also appended to the bundle ID, so each team signs its own ID.
If you write a new module by hand, apply the KMP library plugin before the Compose convention
(the generated modules already do).
Checks
./gradlew detekt runs the static analysis and ./gradlew allTests every
test.
CI (optional)
Tick GitHub Actions CI in the wizard to get .github/workflows/ci.yml.
It runs detekt (when the project has it), the unit tests and the Android and iOS builds on every
pull request, and by hand from the Actions tab (workflow_dispatch); pushes to
main do not start it. The iOS job runs on a macOS runner, which GitHub bills at a
higher rate. Without the option the project has no .github/ folder.
Release
Release builds are minified with R8 and use androidApp/proguard-rules.pro. Copy
keystore.properties.example to keystore.properties (kept out of git)
to sign the build; without it the release build is unsigned. Set the production API address as
described in Networking → Base URL.
Deep links
Every generated app opens links with its own scheme, taken from the project name; there is no wizard option for it.
The scheme
The scheme is your project's name in lower case: a project named MyApp opens
myapp:// links. The generator writes it in three places that must stay in step:
DEEP_LINK_SCHEME in core/navigation/.../DeepLinkConfig.kt, the
android:scheme of MainActivity's intent filter in
AndroidManifest.xml, and CFBundleURLSchemes in
iosApp/iosApp/Info.plist. Android (onCreate and
onNewIntent, launchMode="singleTask") and iOS
(.onOpenURL in iOSApp.swift) both hand the link to the same Kotlin
handleDeepLink(uri); DeepLinkHandler matches it to a route and
navigates through the NavigationManager, like any other screen.
- Showcase:
myapp://user/{username}opens that GitHub profile on Home (a name that is not a valid GitHub username just opens the app), andmyapp://settingsopens Settings. - Blank:
myapp://opens the start screen; every custom module has its own path, its name:myapp://ordersfor a module namedorders. - A path no route knows just opens the app. If two routes have the same path, the first one wins and a warning is logged.
Adding a route
A link maps to a screen through a DeepLinkRoute, registered with Koin like a
FeatureEntry. Its pattern is the path after the scheme; a
{name} segment is a parameter. destination(arguments) returns the
screen's destination, or null when an argument is not valid. Links come from
outside the app: check every argument before you use it.
// feature/orders/presentation/.../navigation/OrderDeepLink.kt
// myapp://orders/42 opens order 42
@Single(binds = [DeepLinkRoute::class])
@Named("OrderDeepLink")
internal class OrderDeepLink : DeepLinkRoute {
override val pattern: String = "orders/{id}"
override fun destination(arguments: Map<String, String>): NavigationCommand.Destination? =
arguments["id"]
?.toLongOrNull()
?.let { OrderDetailDestination(orderId = it) }
}
Every generated feature module already has one: <Module>DeepLink in its
presentation module's navigation package, with the module's name as its path. Add
a {id} segment there to open a single item.
Trying a link
With the app installed on a running emulator or simulator:
# Android emulator
adb shell am start -a android.intent.action.VIEW -d "myapp://settings"
# iOS simulator
xcrun simctl openurl booted "myapp://user/octocat"
App Links and Universal Links
https links to your own domain are ready but off (APP_LINK_HOST = ""). Turning
them on takes three steps together:
- Set
APP_LINK_HOSTinDeepLinkConfig.ktto your domain (e.g.example.com). - Android: uncomment the
autoVerifyintent filter inAndroidManifest.xmlwith your host, and servehttps://example.com/.well-known/assetlinks.jsonwith your package name and your signing certificate's SHA-256 (keytool -list -v -keystore …; with Play App Signing, the Play Console shows it). - iOS: serve
https://example.com/.well-known/apple-app-site-association(applinks→ yourTEAMID.bundleId), copyiosApp/Configuration/UniversalLinks.entitlements.exampletoiosApp/iosApp/iosApp.entitlementswith your domain, addCODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlementstoConfig.xcconfigand enable Associated Domains for the App ID.
Then https://example.com/settings opens the same route as
myapp://settings.
Scheme collisions
A custom scheme is not owned by anyone: another app may claim the same one. For a more unique
scheme, use your package name (e.g. com.example.app) in
DeepLinkConfig.kt, AndroidManifest.xml and Info.plist
together, or turn on App Links and Universal Links, which only your domain can claim.
Dependency Injection
We use Koin with auto-annotation for efficient and scalable dependency injection.
1. Create a Module
To create a new Koin module, define a class annotated with @Module and
@ComponentScan. This tells Koin to scan the specified package for annotated
components. (Feature packages are <your.package>.feature.<module>.<layer> in both
templates, e.g.
@ComponentScan("dev.cmpose.multiplatform.feature.home.presentation").)
@Module
@ComponentScan("dev.cmpose.multiplatform.feature.home.presentation")
class HomePresentationModule
2. Register the Module
Add your module to appModules() in the shared module
(shared/src/commonMain/.../di/initKoin.kt); initKoin starts Koin
with that list.
// The platform entry points start Koin: Android's Application, iOS's startKoinAtLaunch().
fun initKoin(
isDebug: Boolean,
config: KoinAppDeclaration? = null
) {
startKoin {
config?.invoke(this)
modules(module { single { BuildEnvironment(isDebug) } })
modules(appModules())
}
}
// ...
fun appModules(): List<KoinModule> = buildList {
add(NavigationModule().module)
add(OnboardingPresentationModule().module)
add(OnboardingDataModule().module)
add(OnboardingDomainModule().module)
add(NetworkModule().module)
add(CmpModule().module)
add(HomeDataModule().module)
add(HomeDomainModule().module)
add(HomePresentationModule().module)
add(DatabaseModule().module)
add(MultiLangModule().module)
}
Koin starts once per process: Android calls initKoin from
Application.onCreate, iOS from iOSApp.init (through
startKoinAtLaunch() in MainViewController.kt). Never call it from a
composable or from the view-controller factory: iPadOS can create a view controller per
window, and a second startKoin throws.
Both pass isDebug: Android from FLAG_DEBUGGABLE, iOS from
Platform.isDebugBinary; Koin provides it as BuildEnvironment
(core/domain).
KoinGraphTest (shared/src/androidHostTest/.../di/KoinGraphTest.kt)
checks the whole graph: it fails, naming the type, when a class needs something no module in
appModules() defines, for example a feature's presentation module added without its
domain module. Run it with ./gradlew :shared:testAndroidHostTest;
./gradlew allTests runs every test on the JVM and the iOS simulator. Each module's
commonTest already has kotlin-test, kotlinx-coroutines-test
and Turbine.
3. Auto-Annotation
Once your module is set up, you can use Koin annotations to automatically register your classes. Common annotations include:
@Single: Creates a singleton definition.@Factory: Creates a factory definition (new instance each time).@KoinViewModel: Registers a ViewModel.
Network Architecture
Learn how to handle network requests with type-safe state management using Ktor and Flow. Coil's network fetcher lives here too: with network off, images load only from local sources.
1. Base URL
ApiConfig.kt holds two constants: a debug build uses the first, a release build the
second. NetworkModule picks one with ApiConfig.baseUrl(environment).
/**
* The API the app talks to. Debug builds use DEBUG_BASE_URL, release builds RELEASE_BASE_URL.
* Keep the trailing slash and write request paths without a leading one.
*/
object ApiConfig {
const val DEBUG_BASE_URL = "https://api.github.com/"
const val RELEASE_BASE_URL = "https://api.github.com/"
fun baseUrl(environment: BuildEnvironment): String =
if (environment.isDebug) DEBUG_BASE_URL else RELEASE_BASE_URL
}
Keep the trailing / on both constants and write request paths without a leading
one.
2. RestResult - State Management
We use a sealed class RestResult to represent the three states of a network
request:
Success, Error, and Loading. This provides
type-safe state handling throughout your app.
Error carries the reason as a DataError, so the screen can say what
went wrong.
sealed class RestResult<out T> {
class Success<out T>(val result: T) : RestResult<T>()
data class Error(val error: DataError) : RestResult<Nothing>()
class Loading(val loading: Boolean) : RestResult<Nothing>()
}
The four reasons (core/domain):
sealed interface DataError {
/** No connection, a timeout or an unknown host. */
data object Network : DataError
/** The server answered with [code], a status outside 2xx. */
data class Http(val code: Int) : DataError
/** The response body could not be read into the expected type. */
data object Serialization : DataError
/** Any other failure; [cause] is kept for logging. */
data class Unknown(val cause: Throwable) : DataError
}
3. BaseRepository - Request Handler
The BaseRepository provides a reusable request function that wraps
HTTP calls, turns every failure into RestResult.Error with its DataError,
and rethrows CancellationException: a cancelled request (the screen was left, a newer
search started) stops instead of becoming an error. All repositories extend this base class.
(core/network is in the showcase and in blank projects with Network.)
open class BaseRepository {
// CancellationException is rethrown: the caller was cancelled (the screen was left, a newer
// search started) and has to stop here instead of receiving an error result
suspend inline fun <reified T : Any> request(
crossinline call: suspend () -> HttpResponse
): RestResult<T> = withContext(context = Dispatchers.IO) {
try {
call.invoke().asRestResult()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
RestResult.Error(e.toDataError())
}
}
}
The client sets expectSuccess = true, so a status outside 2xx arrives as Ktor's ResponseException. toDataError() maps it and the other failures:
fun Throwable.toDataError(): DataError = when (this) {
is ResponseException -> DataError.Http(code = response.status.value)
is IOException -> DataError.Network
is NoTransformationFoundException, is ContentConvertException -> DataError.Serialization
is SerializationException -> DataError.Serialization
else -> DataError.Unknown(cause = this)
}
4. Mapping Success Results
When you need to transform the response model (e.g., map API response to domain model), use the
mapOnSuccess extension. It only transforms Success results, leaving
Error and Loading unchanged.
inline fun <T, R> RestResult<T>.mapOnSuccess(map: (T?) -> R): RestResult<R> = when (this) {
is RestResult.Success -> RestResult.Success(map(result))
is RestResult.Error -> this
is RestResult.Loading -> this
}
5. Repository Implementation
Here's how to implement a repository. Use @Single to register it with Koin, extend
BaseRepository, and use mapOnSuccess to transform responses
(showcase: feature/onboarding/data). request already runs
the call on Dispatchers.IO; no withContext is needed around it.
@Single(binds = [GithubRepository::class])
internal class GithubRepositoryImpl(private val httpClient: HttpClient) :
BaseRepository(),
GithubRepository {
override suspend fun getUsers(username: String): RestResult<List<GithubUserListModel>> =
request<GithubUserListResponse> {
httpClient.request { reqUsers(username = username) }
}.mapOnSuccess {
githubUserListMapper(response = it)
}
}
6. UseCase with buildDefaultFlow
UseCases call repositories and wrap the result in a Flow. The buildDefaultFlow
extension automatically emits Loading(true) at the start,
Loading(false) on completion, and catches errors (showcase:
feature/onboarding/domain).
@Factory
class SearchGithubUsersUseCase(@Provided private val repository: GithubRepository) {
operator fun invoke(username: String): Flow<RestResult<List<GithubUserListModel>>> = flow {
val result = repository.getUsers(username = username)
emit(result)
}.buildDefaultFlow(Dispatchers.IO)
}
buildDefaultFlow Extension
This extension automates loading state management. It emits Loading(true) when the
flow starts, turns an exception into Error(DataError.Unknown(cause)) (a
CancellationException passes through, so a cancelled request never shows as a
failure), and finally emits Loading(false) when complete.
inline fun <reified T> Flow<RestResult<T>>.buildDefaultFlow(
dispatcher: CoroutineDispatcher,
loading: Boolean = true
): Flow<RestResult<T>> = this.onStart {
emit(RestResult.Loading(loading))
}.catch { cause ->
// A cancelled request is not a failure: it goes on up instead of becoming an Error
if (cause is CancellationException) throw cause
emit(RestResult.Error(DataError.Unknown(cause = cause)))
}.onCompletion {
emit(RestResult.Loading(false))
}.flowOn(dispatcher)
7. ViewModel - Handling States
In the ViewModel, use onSuccess, onError and onLoading to
update the UI state. This is the showcase's OnboardingViewModel
(feature/onboarding/presentation): typing searches once the query has not changed for
SEARCH_DEBOUNCE_MILLIS (400 ms); a newer query, a retry or a cleared field cancels the
search still running; a blank query sends no request. onError receives the
DataError, which the state keeps until the next request starts.
@KoinViewModel
internal class OnboardingViewModel(
@Provided private val navigationManager: NavigationManager,
@Provided private val searchGithubUsers: SearchGithubUsersUseCase
) : CoreViewModel(),
OnboardingActions {
private val _uiState = MutableStateFlow(OnboardingUiState())
val uiState: StateFlow<OnboardingUiState>
get() = _uiState
// ...
override fun onRetry() {
search(query = _uiState.value.query, debounceMillis = 0L)
}
// ...
private fun search(
query: String,
debounceMillis: Long
) {
searchJob?.cancel()
if (query.isBlank()) {
searchJob = null
_uiState.update { it.copy(users = emptyList(), isLoading = false, error = null) }
return
}
_uiState.update { it.copy(isLoading = false) }
searchJob = viewModelScope.launch {
delay(debounceMillis)
_uiState.update { it.copy(isLoading = true, error = null) }
safeFlowApiCall {
searchGithubUsers.invoke(username = query)
}.onSuccess { users ->
_uiState.update { it.copy(users = users) }
}.onError { error ->
_uiState.update { it.copy(error = error) }
}.onLoading { isLoading ->
_uiState.update { it.copy(isLoading = isLoading) }
}.collect()
}
}
}
The message comes from one place, errorMessage(error) next to the strings in
core/multilang, which both showcase screens use (GitHub answers 403 or 429 once its
request limit is used up):
// GitHub answers 403 (primary limit) or 429 (secondary limit) once its request limit is used up
private const val HTTP_FORBIDDEN = 403
private const val HTTP_TOO_MANY_REQUESTS = 429
/** The message a screen shows for [error]; Home and Onboarding both take it from here. */
fun StringResourcesUiModel.errorMessage(error: DataError): String = when (error) {
DataError.Network -> errorNoConnection
is DataError.Http -> if (error.isRateLimit()) errorRateLimit else errorServer
DataError.Serialization, is DataError.Unknown -> errorUnexpected
}
ErrorContent (core/designsystem) shows it with a Try again
button that calls onRetry(); it takes plain strings, so it does not depend on the
string source. HomeViewModel does the same for the profile, and its back button stays
on every state:
@Composable
fun ErrorContent(
message: String,
retryLabel: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(all = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
KtText(
text = message,
textAlign = TextAlign.Center,
color = LocalColorsPalette.current.textBlack
)
Button(onClick = onRetry) {
KtText(text = retryLabel)
}
}
}
8. Flow Extensions for State Handling
These three extensions (onSuccess, onError, onLoading)
allow you to handle each RestResult state in a clean, functional way. They use
transform to react to specific states while still emitting all results downstream.
fun <T> Flow<RestResult<T>>.onSuccess(action: suspend (T) -> Unit): Flow<RestResult<T>> =
transform { restResult ->
if (restResult is RestResult.Success) {
action.invoke(restResult.result)
}
emit(restResult)
}
fun <T> Flow<RestResult<T>>.onError(action: suspend (DataError) -> Unit): Flow<RestResult<T>> =
transform { restResult ->
if (restResult is RestResult.Error) {
action.invoke(restResult.error)
}
emit(restResult)
}
fun <T> Flow<RestResult<T>>.onLoading(action: suspend (Boolean) -> Unit): Flow<RestResult<T>> =
transform { restResult ->
if (restResult is RestResult.Loading) {
action.invoke(restResult.loading)
}
emit(restResult)
}
How It Works
- onSuccess: Executes the action when a
Successresult is emitted, typically used to update UI state with data. - onError: Executes the action with the
DataErrorwhen anErrorresult is emitted; keep it in the state and show its message. A lambda that ignores the reason (onError { … }) still works. - onLoading: Executes the action when a
Loadingresult is emitted, used to show/hide loading indicators.
All three extensions use transform to perform side effects while still emitting the
original result, allowing you to chain multiple handlers together.
Debug builds log each request; see Logging.
For the offline banner and retrying when the connection comes back, see Connectivity & updates.
Logging with Kermit
Every project ships a small logging module: one setup, the same logger on Android and iOS.
What gets installed
Every project has the core/logging module, whatever you pick in the wizard. It
brings Kermit to every module, and
initKoin sets it up once with configureLogging(isDebug):
- Debug builds write every level, Verbose and up.
- Release builds write only Warn and Error.
- Android writes to Logcat, iOS to OSLog.
- Every tag starts with your project name: a
Networklogger writes as<Project>/Network.
How to log
Create one logger per class (or per file) with a short English tag, and pass the message as a lambda: a level that is off never builds the text.
import co.touchlab.kermit.Logger
class CheckoutRepository(private val api: CheckoutApi) {
private val log = Logger.withTag("Checkout")
suspend fun pay(orderId: String): Boolean = try {
api.pay(orderId)
log.d { "Payment sent" }
true
} catch (e: PaymentException) {
log.w(e) { "Payment failed" }
false
}
}
No build file change is needed: the convention plugin adds core/logging to every
Kotlin Multiplatform module, including the ones you add later.
What the template logs
- Failed requests, with the HTTP status code or the error type only.
- In debug builds, one line per request (method, URL, status).
- Ad and purchase warnings (consent, load and show failures, purchase errors).
- One start-up line when Koin starts.
What never goes into a log
Tokens, API keys, request and response bodies, headers, and user data (names, IDs, e-mail addresses). Logs end up on devices and in bug reports, so write what happened, not the data. Keep exception messages out too: they often carry URLs or response text.
No println
The template has no println; use a Kermit logger instead. Detekt's
ForbiddenMethodCall rule flags println, but only with type
resolution (the IDE plugin and the type-resolving detekt tasks). A plain
./gradlew detekt runs without it and does not catch the call, so search for
println( before a release.
Reading the logs
- Android: in Android Studio's Logcat, filter with
tag:<Project>/. - iOS: run from Xcode and read its console, or open Console.app and filter by the same tag prefix.
Logs in tests
kermit-test gives you a TestLogWriter that keeps every log line, so a
test can check what was logged. Add implementation(libs.kermit.test) to the
module's commonTest dependencies if it is not there yet.
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import co.touchlab.kermit.TestLogWriter
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class CheckoutRepositoryTest {
private val logWriter = TestLogWriter(loggable = Severity.Verbose)
@BeforeTest
fun setUp() {
Logger.setLogWriters(logWriter)
Logger.setMinSeverity(Severity.Verbose)
}
@Test
fun failedPaymentIsLoggedAsWarning() = runTest {
CheckoutRepository(FailingCheckoutApi()).pay("order-1")
val entry = logWriter.logs.last()
assertEquals(Severity.Warn, entry.severity)
assertEquals("Checkout", entry.tag)
}
}
Connectivity & updates
An offline banner, a retry when the connection comes back, and app update checks on Android and iOS without a server of your own.
ConnectivityMonitor
ConnectivityMonitor tells you whether the device is online:
isOnline is a StateFlow<Boolean>.
- Interface: in
core/domain, so every feature module can inject it with@Provided. - Implementation: in
core/network(connectivity/). Android usesConnectivityManager(online = a validated internet connection, with theACCESS_NETWORK_STATEpermission); iOS usesNWPathMonitor. - It is there only when Network (Ktor) is on (the showcase always has it).
With network off,
core/network, the monitor and the permission are gone.
The offline banner
OfflineBanner (in shared) draws a thin "No connection" bar above
the app while the device is offline; MainScreen shows it as the
Scaffold's top bar. It finds the monitor through Koin and draws nothing when
there is none, so the same MainScreen works with network off. The showcase
translates the text into its five languages; the blank template passes
"No connection".
Retry when back online
reconnections() emits once each time the device goes from offline to online
(not for the first value, and not while it stays online). In the showcase, Home and
Onboarding load again after a connection error; an HTTP or parsing error is not retried, and
the "Retry" button works as before. In your own modules, use the same pattern:
@KoinViewModel
class ProfileViewModel(
private val getProfileUseCase: GetProfileUseCase,
@Provided private val connectivityMonitor: ConnectivityMonitor
) : ViewModel() {
private val _uiState = MutableStateFlow(ProfileUiState())
val uiState = _uiState.asStateFlow()
init {
loadProfile()
// Back online after a connection error: load again (an HTTP or parsing error is not retried)
connectivityMonitor.reconnections()
.onEach { if (_uiState.value.error == DataError.Network) loadProfile() }
.launchIn(viewModelScope)
}
fun onRetry() = loadProfile()
private fun loadProfile() {
// getProfileUseCase(...) and buildDefaultFlow, as in the Network section
}
}
App updates (core/update)
Every project has the core/update module, whatever you pick in the wizard.
MainScreen calls AppUpdateEffect() once; it checks at most once a
day.
- Android, suggested update: Play In-App Updates, the "flexible" flow. Play shows its own download screen; when the download is done, a dialog offers "Restart".
- iOS, suggested update: the iTunes lookup for your bundle ID finds the App Store version; when it is newer, an "Update available" dialog opens the App Store page.
- An app that was not installed from Google Play, or is not on the App Store yet, skips the check quietly.
Forced update. MIN_VERSION_URL is empty, so there is no forced
update. Fill it in and the app reads this JSON at start-up:
{ "minVersion": { "android": 12, "ios": "1.4.0" } }. android is
compared with the versionCode, ios with
CFBundleShortVersionString. When the installed version is lower, a full-screen
"Update required" screen opens and cannot be dismissed; once a check says "required", the app
checks again on every start.
// core/update/src/commonMain/kotlin/<your package>/core/update/UpdateConfig.kt
// Empty: no forced update. Point it at a JSON file you host to turn the "Update required" screen on.
const val MIN_VERSION_URL = "https://example.com/min-version.json"
The update check uses the platform's own HTTP client (not Ktor), so it works with network off
too. On Android, with Network, In-App Purchases and Ads all off, the generator removes the
INTERNET permission; if you fill in MIN_VERSION_URL in such a
project, add <uses-permission android:name="android.permission.INTERNET" />
back to AndroidManifest.xml.
Testing
- Offline banner: turn on airplane mode on the Android emulator (the iOS simulator uses the Mac's connection: turn off its Wi-Fi); the banner shows, and goes away when you turn it back on.
- Android update flow: Play's
FakeAppUpdateManagerdrives the flow in tests. On a device, the real flow needs an app installed from Google Play: upload two builds to an internal test track and install the older one from Play. - iOS update dialog: it shows only once the app is on the App Store with a newer version than the one installed.
- Forced update: host a JSON file whose minimum version is above your
build's, set
MIN_VERSION_URL, and start the app.
Code Quality with Detekt
Enforce code quality and consistency using Detekt static code analysis for Kotlin.
What is Detekt?
Detekt is a static code analysis tool for Kotlin that helps you maintain code quality by detecting code smells, complexity issues, and potential bugs. You can customize rules to match your team's coding standards.
Custom Rules Configuration
You can upload your custom detekt.yml configuration file during project setup. If
you skip this step, you can always add or modify rules later by locating the
detekt/detekt.yml file in your project and editing it directly. The project uses
Detekt 2 (plugin dev.detekt); an excerpt of the default file:
config:
validation: true
warningsAsErrors: true
# ...
style:
MagicNumber:
active: true
# ...
MaxLineLength:
active: true
maxLineLength: 120
# ...
Implementation
There are two ways to use Detekt in your project: through convention plugins (recommended) or manual implementation.
Option 1: Convention Plugins (Recommended)
If you use the project's convention plugins (KmpLibraryConventionPlugin, the
FeatureContractConventionPlugin, FeatureDataConventionPlugin,
FeatureDomainConventionPlugin and FeaturePresentationConventionPlugin
built on it, and ComposeMultiplatformAppConventionPlugin for
androidApp), Detekt is already configured and applied
automatically. These convention plugins have Detekt built-in, so you don't need to
do anything extra. (In blank projects, only when Detekt was selected in the wizard.)
Simply apply the appropriate convention plugin to your module's build.gradle.kts,
and Detekt will work out of the box:
plugins {
alias(libs.plugins.kmp.library.plugin) // Convention plugin with Detekt already included
// feature modules: alias(libs.plugins.feature.presentation.plugin), .feature.data.plugin, ...
}
The convention plugins internally apply the Detekt configuration, so there's no additional setup required.
Option 2: Manual Implementation
If you prefer not to use convention plugins or need to add Detekt to a module that doesn't use
them, you can manually apply the Detekt convention plugin. Its id starts with your package:
<your.package>.detekt (for example
dev.cmpose.multiplatform.detekt).
plugins {
// Other plugins...
id("<your.package>.detekt") // e.g. id("dev.cmpose.multiplatform.detekt")
}
Running Detekt
To analyze your code with Detekt, run the following Gradle command from your project root:
./gradlew detekt
Detekt will analyze all modules with the plugin applied and generate a report showing any code quality issues, violations, and suggestions for improvement.
Data Persistence with DataStore
Learn how to implement cross-platform data storage using DataStore with the expect/actual pattern.
What is DataStore?
DataStore is a data storage solution that allows you to store key-value pairs asynchronously. In
Compose Multiplatform, we use the expect/actual pattern to handle
platform-specific implementations for iOS and Android. The files below live in
core/database (showcase, and blank projects with DataStore, Theming or
MultiLanguage). With Theming, core/database also holds the saved theme choice
(theme/).
1. Common Implementation
In your commonMain source set, define the expected platform-specific function and
the shared factory function:
expect fun getPlatformDataStore(): DataStore<Preferences>
fun createDataStore(producePath: () -> String): DataStore<Preferences> =
PreferenceDataStoreFactory.createWithPath(
corruptionHandler = null,
migrations = emptyList(),
produceFile = { producePath().toPath() }
)
internal const val DATA_STORE_FILE_NAME = "multiplatform.preferences_pb"
You can customize the DATA_STORE_FILE_NAME to match your app's naming convention. The
createDataStore function is a helper that both platforms will use.
2. Android Implementation
In your androidMain source set, provide the actual implementation using the Android
Context:
fun createAndroidDataStore(context: Context): DataStore<Preferences> = createDataStore(
producePath = { context.filesDir.resolve(DATA_STORE_FILE_NAME).absolutePath }
)
private lateinit var dataStore: DataStore<Preferences>
actual fun getPlatformDataStore(): DataStore<Preferences> {
if (::dataStore.isInitialized.not()) {
val context: Context = getKoin().get()
dataStore = createAndroidDataStore(context)
}
return dataStore
}
On Android, we lazily initialize the DataStore using the application Context retrieved from Koin. The file is stored in the app's internal files directory.
3. iOS Implementation
In your iosMain source set, provide the actual implementation using iOS file system
APIs:
@OptIn(ExperimentalForeignApi::class)
private fun createIOSDataStore(): DataStore<Preferences> = createDataStore(
producePath = {
val documentDirectory: NSURL? = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null
)
requireNotNull(documentDirectory).path + "/$DATA_STORE_FILE_NAME"
}
)
private val dataStore: DataStore<Preferences> by lazy {
createIOSDataStore()
}
actual fun getPlatformDataStore(): DataStore<Preferences> = dataStore
On iOS, we use NSFileManager to locate the Documents directory and store the
DataStore file there. The instance is lazily initialized using Kotlin's lazy
delegate.
4. Usage Example
Once the platform-specific implementations are in place, you can use DataStore in your common
code by injecting it through Koin (DatabaseModule provides it with
@Single fun provideDataStore() in DataStoreModule.kt). The
theme preference is stored like this
(core/database/.../theme/DarkModeManagerImpl.kt, in the showcase and in blank
projects with Theming):
@Single(binds = [DarkModeManager::class])
class DarkModeManagerImpl(@Provided private val dataStore: DataStore<Preferences>) : DarkModeManager {
// Stored by name: reordering or adding ThemeMode entries never changes a saved choice
private val themeModeKey = stringPreferencesKey("themeMode")
override fun getThemeMode(): Flow<ThemeMode> =
dataStore.data.map { preferences -> ThemeMode.fromName(preferences[themeModeKey]) }
override suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = mode.name
}
}
}
From here, you can use standard DataStore APIs to read and write preferences. The platform-specific storage handling is completely abstracted away.
For structured data (tables, queries), see Room.
Local Database with Room
Store structured data in SQLite with Room KMP: one database, shared by Android and iOS.
What gets installed
Turning on Room Database adds the core/room module. It uses the
bundled SQLite driver, so Android and iOS run the same SQLite and need no extra linker flag.
The module holds:
AppDatabase: the app's one Room database, with its entity list and version.DatabaseFactory.kt: builds the database with the bundled driver; queries run onDispatchers.IO.di/RoomModule.kt: Koin provides the database and every DAO. A module that uses a DAO takes it with@Provided.schemas/: the exported schema of every database version.
Add a table
A table is an entity and a DAO, each in its own package. List the entity in
@Database(entities = ...), add an abstract function for the DAO to
AppDatabase and a @Single provider below it. This is the
showcase's favorites table:
@Entity(tableName = "favorite_users")
data class FavoriteUserEntity(
@PrimaryKey val login: String,
val avatarUrl: String,
val name: String?,
val addedAt: Long
)
@Dao
interface FavoriteUserDao {
@Query("SELECT * FROM favorite_users ORDER BY addedAt DESC")
fun observeAll(): Flow<List<FavoriteUserEntity>>
@Query("SELECT EXISTS(SELECT 1 FROM favorite_users WHERE login = :login)")
fun observeIsFavorite(login: String): Flow<Boolean>
@Upsert
suspend fun upsert(user: FavoriteUserEntity)
@Query("DELETE FROM favorite_users WHERE login = :login")
suspend fun deleteByLogin(login: String)
}
// The app's Room database. To add a table: an @Entity and a @Dao in their own package, the entity in
// `entities`, an abstract function for the DAO and a @Single provider below. Changing a table needs a
// new `version` and a migration; core/room/schemas/ keeps every version's schema (commit it).
@Database(
entities = [FavoriteUserEntity::class],
version = 1,
exportSchema = true
)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun favoriteUserDao(): FavoriteUserDao
}
// Room generates the actual object for each platform (KSP)
@Suppress("KotlinNoActualForExpect")
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}
@Single
fun provideFavoriteUserDao(database: AppDatabase): FavoriteUserDao = database.favoriteUserDao()
@Suppress("KotlinNoActualForExpect") is there because Room (KSP) generates the
actual object for each platform, so the compiler sees an expect with
no actual in your sources. The DAO provider lives in the same module as the
database, so it takes no @Provided.
Version, migrations and schemas
Once the app has shipped, changing a table needs a higher version and a
migration (autoMigrations or a Migration), or users with an older
database lose their data. Room writes the schema of each version to
core/room/schemas/<package>.core.room.AppDatabase/<version>.json.
Commit these files: they are what migrations are checked against.
Showcase: favorites
In the Showcase template the star on a GitHub profile saves the user, and the onboarding
screen lists the saved users live. feature/home keeps Room out of the screens
with a repository: the interface sits in domain, the implementation (which takes
the DAO) in data. Use the same pattern for your own tables.
interface FavoritesRepository {
fun observeFavorites(): Flow<List<FavoriteUserModel>>
fun observeIsFavorite(login: String): Flow<Boolean>
suspend fun add(user: FavoriteUserModel)
suspend fun remove(login: String)
}
@Single(binds = [FavoritesRepository::class])
class FavoritesRepositoryImpl(@Provided private val favoriteUserDao: FavoriteUserDao) : FavoritesRepository {
override fun observeFavorites(): Flow<List<FavoriteUserModel>> =
favoriteUserDao.observeAll().map { rows -> rows.map { it.toFavoriteUserModel() } }
override fun observeIsFavorite(login: String): Flow<Boolean> =
favoriteUserDao.observeIsFavorite(login = login)
override suspend fun add(user: FavoriteUserModel) {
favoriteUserDao.upsert(user = user.toFavoriteUserEntity(addedAt = Clock.System.now().toEpochMilliseconds()))
}
override suspend fun remove(login: String) {
favoriteUserDao.deleteByLogin(login = login)
}
}
Blank: the Notes demo
A Blank project with Room gets feature/notes, a small screen to add and delete
notes, wired into shared. Its ViewModel calls NoteDao directly to keep
the demo short. In a real project, put a repository in a data layer and keep Room
out of the screen, as favorites do.
Testing a DAO
The Showcase tests its DAO on iOS (core/room/src/iosTest) against an
in-memory database built with Room.inMemoryDatabaseBuilder and the bundled
driver. A Blank project starts without tests: copy this pattern for your DAOs. There is no
such test for Android: the Android in-memory builder needs a Context, which a
plain JVM host test does not have, so DAOs are checked on iOS and everything above them
(repositories, ViewModels) with fakes in commonTest.
Room or DataStore
DataStore keeps a few key-value settings (theme, language, flags). Room keeps tables of rows
you query, sort and observe as a Flow. If you would otherwise serialize a list into
one preference, use Room.
Backend: server, sign-in and sharing
A Ktor server in the same project, Firebase sign-in in the app and shared spaces with invites. Blank only.
What ships
Turning on Backend (server, sign-in, sharing) also turns on Multi-Language and Network (and, through Multi-Language, DataStore). The project gets:
server/: the Ktor server. It checks the Firebase ID token of every request and stores spaces, members, invites and files; it has its own tests (./gradlew :server:test).core/api: the models and routes the app and the server share.core/auth: Firebase sign-in (email, Google, Apple) and the token the server client sends.feature/account: the sign-in screens and the account screen, account deletion included.feature/sharing: shared spaces, roles and invites. An invite opens the app through<scheme>://invite/<token>(the scheme is your project name in lower case); a signed-out user signs in first.
account and sharing are reserved module names. Your own modules sit
next to them as usual. The Showcase template never has the backend.
Run the server locally
From the project root, run ./gradlew :server:runLocal. It starts the server with
an embedded database and prints LOCAL_SERVER_READY when it accepts requests;
GET /health answers 200. Its data, uploaded files and its
config.properties (where firebaseProjectId names your Firebase
project) live in .local-server/, which is git-ignored. Debug builds of the app
talk to this local server.
Add your Firebase config files
The project ships without Firebase config. It builds and runs without it; the sign-in screen then says Firebase is not configured. To sign in:
- Create a Firebase project and turn on the Email/Password, Google and Apple sign-in providers.
- Add an Android app with your package name and save its config as
androidApp/google-services.json. - Add an iOS app with your bundle identifier and save its config as
iosApp/iosApp/GoogleService-Info.plist. Copy its reversed client ID intoGOOGLE_REVERSED_CLIENT_IDinConfig.xcconfig: Google Sign-In returns to the app through that URL scheme ($(GOOGLE_REVERSED_CLIENT_ID)inInfo.plist). Sign in with Apple also needs your Apple team.
Both config files are in .gitignore: they are per developer and are never
committed.
Emulator and simulator only, for now
The app reaches the local server from the Android emulator and the iOS simulator. A physical phone and a deployed server are not set up yet: to ship, host the server yourself and point the release build at it.
Privacy declarations
With the backend, iosApp/iosApp/PrivacyInfo.xcprivacy declares the email
address the app collects for sign-in:
NSPrivacyCollectedDataTypeEmailAddress, linked to the user, not used for
tracking, for app functionality. Declare the same in App Store Connect's privacy details and
in Google Play's Data safety form, together with anything your users store on your server,
and link your privacy policy. Both stores require in-app account deletion for apps with
sign-up: the account screen has it.
Images
Coil 3 loads images in every project through one shared ImageLoader in
core/image; there is no wizard option for it.
Loading an image
Every feature presentation module gets core/image from the convention plugin, so
Coil's AsyncImage works without touching a build file. With Theming or
Multi-Language on (and in the showcase), core/designsystem also has
KtAsyncImage: the same call with a placeholder and an error image in your
palette's colors.
// Coil directly: any presentation module
AsyncImage(
model = "https://example.com/avatar.png",
contentDescription = "Avatar",
modifier = Modifier.size(48.dp).clip(CircleShape)
)
// Through the design system (placeholder and error image from the palette)
KtAsyncImage(
image = user.avatarUrl,
contentDescription = "Avatar",
modifier = Modifier.size(48.dp),
contentScale = ContentScale.Crop
)
ImageLoaderSetup and the cache
MainScreen calls ImageLoaderSetup() once, at the top of its body,
before the offline banner, the review and the update effects. It sets Coil's singleton
loader, so every AsyncImage in the app shares one cache:
- Memory cache: 25% of the app's memory
(
MEMORY_CACHE_PERCENT). - Disk cache: 50 MB (
DISK_CACHE_MAX_BYTES) in animage_cachefolder of the platform's cache directory (AndroidcacheDir, iOSNSCachesDirectory), so images you have seen also show offline. - Both values are constants in
ImageCacheConfig.kt; change them there.
Network off: local images only
Coil fetches URLs with coil-network-ktor3, which lives in
core/network and registers itself. With Network (Ktor) off, that
module and the library are gone: http(s) URLs do not load, while local sources
(Compose resources, files) still do.
// A drawable from composeResources/drawable, through Compose resources
Image(
painter = painterResource(Res.drawable.logo),
contentDescription = null
)
// The same file through Coil (its cache and placeholder)
AsyncImage(
model = Res.getUri("drawable/logo.png"),
contentDescription = null
)
Debug logs
In debug builds Coil writes its logs to Kermit with the Image tag; filter Logcat
(or Console.app) with tag:<Project>/Image. A message can contain the image
URL, as Ktor's debug request lines do. Release builds set no Coil logger.
Theming
Light, Dark and System modes through a Material theme built from your color palette; the user's choice is saved with DataStore.
Overview
Theming has three parts. core/designsystem/.../theme/ holds the color palette
(CustomColorsPalette) and KtTheme, which turns the palette into a
Material colorScheme. core/database/.../theme/ holds
DarkModeManager, which saves the user's Light/Dark/System choice in DataStore (so
choosing Theming for a blank project also adds DataStore).
shared/.../AppTheme.kt applies that choice, and MainScreen wraps the
whole app in AppTheme { ... }. Material components you add without explicit colors
(Text, Button, CircularProgressIndicator,
Card) follow the theme.
A blank project without Theming still has AppTheme.kt: a plain
MaterialTheme that follows the system's light/dark setting with Material's
baseline light and dark colors, without DataStore. Put your own colors in
AppTheme.kt; do not add a second MaterialTheme. If your design has a
single color mode, use its color scheme for both light and dark (or drop the
isSystemInDarkTheme() check) and give the Android window the same background
(below).
On Android the window behind the first frame follows the device too (Theme.App in
res/values/themes.xml and res/values-night/themes.xml), so a
dark-mode device never flashes white at launch. When you change the theme's background color,
update window_background in both files; a single-mode app sets both to its
background (or deletes values-night/themes.xml). Known limit: if the user picked
the opposite of the device's mode, the first frame or two show the device's mode, because
DataStore loads the saved choice asynchronously.
1. DarkModeManager
core/database/.../theme/DarkModeManager.kt: read the saved mode as a Flow, save a
new one.
interface DarkModeManager {
fun getThemeMode(): Flow<ThemeMode>
suspend fun setThemeMode(mode: ThemeMode)
}
2. Implementation with DataStore
The choice is stored by name (mode.name), so reordering or adding modes never
changes a saved choice; an unknown or missing value reads as SYSTEM.
@Single(binds = [DarkModeManager::class])
class DarkModeManagerImpl(@Provided private val dataStore: DataStore<Preferences>) : DarkModeManager {
// Stored by name: reordering or adding ThemeMode entries never changes a saved choice
private val themeModeKey = stringPreferencesKey("themeMode")
override fun getThemeMode(): Flow<ThemeMode> =
dataStore.data.map { preferences -> ThemeMode.fromName(preferences[themeModeKey]) }
override suspend fun setThemeMode(mode: ThemeMode) {
dataStore.edit { preferences ->
preferences[themeModeKey] = mode.name
}
}
}
enum class ThemeMode {
LIGHT,
DARK,
SYSTEM;
companion object {
fun fromName(name: String?): ThemeMode = entries.firstOrNull { it.name == name } ?: SYSTEM
}
}
Projects generated before this release keep DarkModeManager in
core/designsystem/.../darkmode/ and save the mode as a number
(intPreferencesKey("themeMode")). If you switch such an app to names, use a new key
(for example themeModeName): reading the old number as text fails.
3. KtTheme: palette to Material
KtTheme picks the light or dark palette, provides it as
LocalColorsPalette and applies MaterialTheme with a color scheme
derived from it: background and surface from
backgroundColor, onBackground and onSurface from
textBlack, onSurfaceVariant from textGray,
primary from tintBlack, onPrimary from
backgroundColor, outline from textFieldUnFocusedBorder.
Other roles keep Material's light or dark defaults. Add typography and shapes to its
MaterialTheme call.
@Composable
fun KtTheme(
darkTheme: Boolean,
content: @Composable () -> Unit
) {
val palette = if (darkTheme) OnDarkColorsPalette else OnLightColorsPalette
CompositionLocalProvider(LocalColorsPalette provides palette) {
MaterialTheme(colorScheme = colorSchemeFor(palette, darkTheme), content = content)
}
}
internal fun colorSchemeFor(
palette: CustomColorsPalette,
darkTheme: Boolean
): ColorScheme = (if (darkTheme) darkColorScheme() else lightColorScheme()).copy(
primary = palette.tintBlack,
onPrimary = palette.backgroundColor,
background = palette.backgroundColor,
onBackground = palette.textBlack,
surface = palette.backgroundColor,
onSurface = palette.textBlack,
onSurfaceVariant = palette.textGray,
outline = palette.textFieldUnFocusedBorder
)
4. AppTheme
shared/.../AppTheme.kt turns the saved mode into light or dark;
SYSTEM follows the device.
@Composable
internal fun AppTheme(
darkModeManager: DarkModeManager = koinInject(),
content: @Composable () -> Unit
) {
val themeModeFlow = remember(darkModeManager) { darkModeManager.getThemeMode() }
val themeMode by themeModeFlow.collectAsState(initial = ThemeMode.SYSTEM)
val darkTheme = when (themeMode) {
ThemeMode.LIGHT -> false
ThemeMode.DARK -> true
ThemeMode.SYSTEM -> isSystemInDarkTheme()
}
KtTheme(darkTheme = darkTheme, content = content)
}
5. Usage
Define Custom Color Palette
Your colors live in CustomColorsPalette with a light and a dark value. The showcase
ships more colors; a blank project with Theming starts with the five KtTheme maps
to Material roles. Add yours to the class and to both palettes:
@Immutable
data class CustomColorsPalette(
val backgroundColor: Color = Color.Unspecified,
val textBlack: Color = Color.Unspecified,
val textGray: Color = Color.Unspecified,
val tintBlack: Color = Color.Unspecified,
val textFieldUnFocusedBorder: Color = Color.Unspecified,
val accent: Color = Color.Unspecified // your own color
)
val OnLightColorsPalette = CustomColorsPalette(
backgroundColor = Color(color = 0xFFFFFFFF),
textBlack = Color(color = 0xFF080B0E),
textGray = Color(color = 0xFF8B959E),
tintBlack = Color(color = 0xFF080B0E),
textFieldUnFocusedBorder = Color(color = 0xFFCCCCCC),
accent = Color(color = 0xFF0A84FF)
)
val OnDarkColorsPalette = CustomColorsPalette(
backgroundColor = Color(color = 0xFF202326),
textBlack = Color(color = 0xFFFFFFFF),
textGray = Color(color = 0x80FFFFFF),
tintBlack = Color(color = 0xFFFFFFFF),
textFieldUnFocusedBorder = Color(color = 0xFF555555),
accent = Color(color = 0xFF409CFF)
)
Changed backgroundColor? Set window_background to the same colors: the
light one in res/values/themes.xml, the dark one in
res/values-night/themes.xml.
Use the Theme
Material components take their colors from MaterialTheme.colorScheme; palette
colors without a Material role come from LocalColorsPalette.current. Both switch
when the mode changes. The showcase's Settings screen (SettingsViewModel) calls
setThemeMode and shows getThemeMode(). DarkModeManager
lives in core:database, so a feature's presentation module that injects it needs
implementation(projects.core.database) in its build.gradle.kts, as the
showcase's feature/onboarding/presentation/build.gradle.kts has.
@Composable
fun MyComponent() {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Hello, World!") // onBackground
Button(onClick = { }) { Text(text = "Continue") } // primary / onPrimary
Text(text = "Details", color = LocalColorsPalette.current.accent)
}
}
Multi-Language Support
Implement dynamic language switching with persistent user preferences and system language detection.
Overview
The multi-language system is built on top of DataStore, allowing users to select their preferred language (or use system default) with preferences persisted across app sessions. The system uses a type-safe resource model for managing translations.
1. LanguageManager Interface
The language management is handled through a singleton LanguageManager interface:
interface LanguageManager {
val currentResources: StateFlow<StringResourcesUiModel>
fun getCurrentLanguageFlow(): Flow<AppLanguage>
suspend fun setLanguage(language: AppLanguage)
suspend fun setSystemLanguage()
}
2. Implementation with DataStore
The implementation persists language preferences and manages resource updates:
@Single(binds = [LanguageManager::class])
class LanguageManagerImpl(@Provided private val dataStore: DataStore<Preferences>) : LanguageManager {
private val selectedLanguageKey = stringPreferencesKey("selectedLanguage")
// Starts from the device language; setSystemLanguage() then applies a saved choice.
private val _currentResources = MutableStateFlow(resourcesFor(AppLanguage.SYSTEM))
override val currentResources: StateFlow<StringResourcesUiModel> =
_currentResources.asStateFlow()
override fun getCurrentLanguageFlow(): Flow<AppLanguage> = dataStore.data.map { prefs ->
val storedCode = prefs[selectedLanguageKey] ?: AppLanguage.SYSTEM.code
AppLanguage.fromCode(storedCode)
}
override suspend fun setLanguage(language: AppLanguage) {
dataStore.edit { prefs ->
prefs[selectedLanguageKey] = language.code
}
_currentResources.update { resourcesFor(language) }
}
override suspend fun setSystemLanguage() {
val prefs = dataStore.data.firstOrNull()
val selectedLanguage = AppLanguage.fromCode(code = prefs?.get(selectedLanguageKey))
_currentResources.update { resourcesFor(selectedLanguage) }
}
// SYSTEM follows the device; a device language without a translation falls back to English.
private fun resourcesFor(language: AppLanguage): StringResourcesUiModel {
val active = if (language == AppLanguage.SYSTEM) {
AppLanguage.fromCode(getDeviceLanguageCode())
} else {
language
}
return languageResources[active] ?: fallbackResources
}
}
How It Works
- setSystemLanguage(): Called on app startup to load the saved language preference
- setLanguage(): Updates the language preference and loads the appropriate resource model
- getCurrentLanguageFlow(): Returns a Flow of the current language for settings UI
- currentResources: StateFlow that provides the active string resources to the UI
- fallbackResources: English. Used for device languages without a translation, so no string is ever shown empty
3. Device Language Detection
To detect the device's system language, we use the expect/actual pattern for platform-specific
implementations. Each source set of core/multilang has its own
language/DefaultLanguage.kt.
Common Declaration (commonMain)
expect fun getDeviceLanguageCode(): String
Android Implementation (androidMain)
actual fun getDeviceLanguageCode(): String = Locale.getDefault().language
iOS Implementation (iosMain)
actual fun getDeviceLanguageCode(): String {
val preferredLanguages = NSLocale.preferredLanguages.firstOrNull() as? String
return preferredLanguages?.substring(0, 2) ?: "en"
}
4. String Resources
String resources are defined in a type-safe data class. This dummy example shows the basic
structure with a single title; the generated project's model holds the showcase
strings instead (settingsTitle, githubSearchHint, ..., and the error texts
errorNoConnection, errorRateLimit, errorServer,
errorUnexpected, retry that errorMessage(error) picks from); a blank project with
MultiLanguage starts with settingsTitle, themeLight,
themeSystem, themeDark and languageSystem:
data class StringResourcesUiModel(
val title: String // no default: a language that misses a string does not compile
)
Create resource models for each supported language, one file per language in
resource/res, and list them in LanguageResources.kt:
// ResourcesEN.kt
internal val resourceEN = StringResourcesUiModel(
title = "Welcome"
)
// ResourceTR.kt
internal val resourceTR = StringResourcesUiModel(
title = "Hoş Geldiniz"
)
// LanguageResources.kt
internal val languageResources = mapOf(
AppLanguage.EN to resourceEN,
AppLanguage.TR to resourceTR
)
val fallbackResources: StringResourcesUiModel
get() = resourceEN
Adding a new language: Simply add the language (its code and
displayName) to your AppLanguage enum, create a new
StringResourcesUiModel object with translations, and add it to the
languageResources map.
5. Usage Example
In your root screen, collect the current resources and provide them to your app using
CompositionLocalProvider. LocalStringResources is already declared in
core/designsystem (language/LocalStringResources.kt): import it, do not
declare it again. The showcase's MainScreen.kt does this, and so does a blank
project generated with MultiLanguage; only a blank project generated before this release
needs it added to its MainScreen.kt:
// core/designsystem/.../language/LocalStringResources.kt (already in the project)
val LocalStringResources = staticCompositionLocalOf { fallbackResources }
// shared/.../MainScreen.kt
import dev.cmpose.multiplatform.core.designsystem.language.LocalStringResources
@Composable
fun MainScreen(languageManager: LanguageManager = koinInject()) {
val resource by languageManager.currentResources.collectAsState()
LaunchedEffect(key1 = Unit) {
languageManager.setSystemLanguage()
}
CompositionLocalProvider(LocalStringResources provides resource) {
Text(text = LocalStringResources.current.settingsTitle)
}
}
The LaunchedEffect ensures that the system language is loaded on app startup.
Throughout your app, access strings using LocalStringResources.current, and they
will automatically update when the user changes their language preference.
In-app review
Every project can ask for a store rating with the system dialog, on Android and iOS.
What gets installed
Every project has the core/review module, whatever you pick in the wizard:
- Android: Google Play In-App Review.
- iOS: the StoreKit rating dialog.
ReviewPromptercounts positive moments and decides when to ask.ReviewRequestEffect()inMainScreenhands each request to the platform. Keep that call.
How to use it
Inject ReviewPrompter and call recordPositiveEvent() right after a
positive moment (an order placed, a favorite added). The first request comes on the 3rd event,
then on every 10th (3, 13, 23, …). The count survives app restarts.
import org.koin.android.annotation.KoinViewModel
import org.koin.core.annotation.Provided
@KoinViewModel
class CheckoutViewModel(
private val repository: CheckoutRepository,
@Provided private val reviewPrompter: ReviewPrompter
) : ViewModel() {
fun onPaymentSucceeded() {
// Right after the good moment, never at launch or in the middle of a task
reviewPrompter.recordPositiveEvent()
}
}
No build file change is needed: the convention plugin adds core/review to every
feature presentation module, including the ones you add later.
"Rate the app"
For a "Rate the app" row, call openStorePage(). On Android it opens your app's
Play Store page, found from the package name. On iOS it opens the App Store review page once
you fill in APP_STORE_ID; while it is empty, the system rating dialog is asked for
instead.
fun onRateAppClick() {
reviewPrompter.openStorePage()
}
// core/review/src/iosMain/kotlin/<your package>/core/review/AppStoreId.kt
internal const val APP_STORE_ID = "1234567890"
Your App Store ID is the number in your app's App Store link, also shown in App Store Connect under App Information (Apple ID).
Rules
- No "Do you like the app?" question before the dialog.
- No custom rating or star screen.
- Never ask at launch or in the middle of a task.
- Whether the dialog shows is the system's decision (it has its own quota); the code never assumes it was shown.
Testing
- Android: the dialog shows only in an app installed from Google Play, for
example from an internal test track. On an emulator or a sideloaded build nothing appears.
For unit tests, Play's
FakeReviewManagerstands in for the real one. - iOS: in debug builds the dialog shows every time; in TestFlight it never shows.
Blank template
The "Rate the app" button on the start screen is a demo of requestReview(). It
goes away when you delete GuideScreen.
Store readiness
Every project ships the version line, legal links, an open source licences screen and an iOS Privacy Manifest, so it is one step closer to the App Store and Google Play.
The core/about module
Every project has the core/about module, whatever you pick in the wizard:
AppInfo: the app version asversionName (versionCode)(AndroidPackageManager, iOSCFBundleShortVersionStringandCFBundleVersion) and the legal links. Inject it with Koin (@Provided val appInfo: AppInfoin a ViewModel).LegalUrls.kt:PRIVACY_POLICY_URLandTERMS_URL, both empty. A link stays hidden while its URL is empty; fill them in before you ship.- Licences screen: AboutLibraries lists the libraries the project really
depends on. The list is generated on every build, so a library you turned off in the wizard
does not show up. Open it with
navigationManager.navigate(LicensesDestination(title = "Open source licenses")); the title is a route argument, so the showcase passes its translated text.
In the showcase, Settings has four new rows: Version, Privacy policy, Terms of use and Open source licenses.
iOS Privacy Manifest
iosApp/iosApp/PrivacyInfo.xcprivacy declares the "required reason" APIs the app
uses. The Kotlin code is linked into the app as a static Shared framework, so the
APIs called by Ktor, okio, DataStore, Room's bundled SQLite, the Kotlin/Native runtime and
coroutines count as the app's own and belong in the app's manifest:
NSPrivacyAccessedAPICategoryUserDefaults(CA92.1): the review counter.NSPrivacyAccessedAPICategoryFileTimestamp(C617.1): okio, DataStore and SQLite reading file metadata in the app container.NSPrivacyAccessedAPICategorySystemBootTime(35F9.1): the monotonic clock behind coroutine delays and timeouts.NSPrivacyAccessedAPICategoryDiskSpace(E174.1): SQLite and okio checking free space before writing.
With Ads (AdMob) on, the manifest declares the device ID, advertising data
and product interaction as collected for third-party advertising, and
NSPrivacyTracking is true. With In-app purchases on,
it declares the purchase history as collected for app functionality. The Google Mobile Ads and
RevenueCat Swift packages carry their own manifests. When you add a library or an API that
needs a reason, update the manifest; in Xcode, Product → Archive, then
Generate Privacy Report in the Organizer shows what the whole app
declares.
Store forms
- App Store Connect: fill in App Privacy to match the manifest and add your privacy policy URL.
- Google Play Console: fill in the Data safety form (ads and purchases collect data through their SDKs) and add the same privacy policy URL.
- Keep Android
versionNameand iOSMARKETING_VERSIONin step; the README's "Before you ship" list has the rest.
Blank template
The About button on the start screen opens a dialog with the version, the
legal links that have a URL and the licences screen. It goes away when you delete
GuideScreen; core/about stays in the project.
Ads with AdMob
Banner, interstitial and rewarded ads with Google's test IDs, behind the consent flow (UMP, then App Tracking Transparency on iOS).
What is included
With Ads (AdMob) on, the project gets core/ads and the
ads/ package of feature/monetization: an ads demo screen that opens
from Settings → Ad examples in the showcase, and from the Ad examples
button on the start screen of a blank project. Android uses Google's GMA Next-Gen SDK
(ads-mobile-sdk) and UMP; iOS uses the Google Mobile Ads SDK as a Swift package
(the Next-Gen SDK is Android-only).
In the Next-Gen SDK, load and full-screen callbacks arrive off the main thread.
AdsController handles this for you: it starts the SDK off the main thread and
shows full-screen ads from the main thread, so your code calls it like any other suspend API.
Banner, interstitial and rewarded
BannerAd() draws a banner. rememberAdsController() returns an
AdsController that gathers consent and shows full-screen ads. Call
gatherConsent() first and request ads only once the returned ConsentStatus has canRequestAds set;
each show call suspends until the ad is closed and returns an AdResult
(Dismissed, Rewarded(amount, type) or Failed(reason)).
@Composable
fun AdsDemoScreen(viewModel: AdsDemoViewModel = koinViewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val controller = rememberAdsController()
// Consent first: no ad is requested before it. A recreated screen asks again
// only while no answer has reached the ViewModel.
LaunchedEffect(viewModel, controller) {
if (!viewModel.uiState.value.consentResolved) {
viewModel.onConsentResult(controller.gatherConsent())
}
}
Column {
if (uiState.consentReady) BannerAd()
Button(
enabled = uiState.consentReady,
onClick = { viewModel.runAd { controller.showInterstitial() } }
) {
Text("Show interstitial")
}
Button(
enabled = uiState.consentReady,
onClick = { viewModel.runAd { controller.showRewarded() } }
) {
Text("Watch rewarded ad")
}
}
}
data class AdsDemoUiState(
val consentResolved: Boolean = false,
val consentReady: Boolean = false,
val coins: Int = 0
)
class AdsDemoViewModel : ViewModel() {
private val _uiState = MutableStateFlow(AdsDemoUiState())
val uiState: StateFlow<AdsDemoUiState> = _uiState.asStateFlow()
fun onConsentResult(status: ConsentStatus) {
_uiState.update { it.copy(consentResolved = true, consentReady = status.canRequestAds) }
}
// viewModelScope outlives the screen, so an ad on screen during a rotation still
// delivers its result; one not yet opened when the screen is recreated is cancelled
fun runAd(show: suspend () -> AdResult) {
viewModelScope.launch {
val result = show()
if (result is AdResult.Rewarded) {
_uiState.update { it.copy(coins = it.coins + result.amount) }
}
}
}
}
Both blocks are sketches of the demo, which also handles premium users, a busy flag and the
privacy options entry, and routes clicks to the screen through a command channel. The
ViewModel holds the consent result as state (consentResolved), so a recreated
screen (rotation, dark mode, language) does not ask again once an answer has arrived. It runs
each show call in viewModelScope: the result of an ad already on screen still
arrives after a rotation, while an ad that has not opened when its screen is recreated or
left is cancelled; the call then ends with a CancellationException and returns
no AdResult.
Launching an ad from rememberCoroutineScope() would lose its result when the
screen is recreated; keep show calls in the ViewModel.
Consent
AdsController.gatherConsent() asks for consent with Google's User Messaging
Platform (UMP), then, on iOS, with App Tracking Transparency, and starts the Mobile Ads SDK
once ads may be requested. The demo gathers consent the first time the ads screen opens; a
real app usually asks at launch, before the first ad screen.
Debug builds force the EEA debug geography (ConsentDebugSettings on Android,
DebugSettings in AdsBridge.swift) so the form always shows; release
builds use the user's real location. Emulators and simulators are test devices; on a physical
device the debug geography also needs the device's test ID (addTestDeviceHashedId
on Android, testDeviceIdentifiers on iOS).
Premium means ad-free
The ads screen asks AdFreeSource whether the user is ad-free. With In-App
Purchases (RevenueCat) on, PremiumAdFreeSource follows the premium
entitlement: premium users see no banner and skip the interstitial (rewarded ads stay
optional). Without RevenueCat the project gets NoPremiumAdFreeSource, so ads
always show. core/ads itself knows nothing about purchases.
// feature/monetization/presentation/.../ads/AdFreeSource.kt
internal interface AdFreeSource {
val isAdFree: Flow<Boolean>
}
// feature/monetization/presentation/.../ads/PremiumAdFreeSource.kt (In-App Purchases on)
@Single(binds = [AdFreeSource::class])
internal class PremiumAdFreeSource(
@Provided private val purchasesManager: PurchasesManager
) : AdFreeSource {
override val isAdFree: Flow<Boolean> = purchasesManager.isPremium
}
Interstitials at natural breaks
InterstitialPacer (core/ads; Koin gives the app one, created at
start) keeps interstitials to natural breaks: none in the first minute of a session and at most
one per session. At a break (a level finished, a list saved) ask shouldShow(); once
the ad was on screen call recordShown(). Premium is your check
(AdFreeSource), not the pacer's. The ads demo's Show interstitial
follows it, so the first minute after launch answers "Interstitial paced".
Replace the test IDs
The project uses Google's public test app and ad units: they always fill and never pay. Before you ship:
- Android: the app ID in two places, with the same value: the
com.google.android.gms.ads.APPLICATION_IDmeta-data inandroidApp/src/main/AndroidManifest.xml(read by UMP) andADMOB_APP_IDincore/ads'sAdUnitIds.android.kt(the Next-Gen SDK takes it at start). - iOS:
GADApplicationIdentifieriniosApp/iosApp/Info.plist. - The banner, interstitial and rewarded units in
AdUnitIds(oneactualper platform incore/ads). - Keep
SKAdNetworkItemsinInfo.plistcurrent with Google's list. - Publish a GDPR message in AdMob → Privacy & messaging (the EEA debug geography applies to debug builds only).
iOS: Swift package and bridge
Kotlin/Native cannot link the Google Mobile Ads SDK directly, so iOS goes through Swift. The
Xcode project depends on the Google Mobile Ads Swift package (it brings UMP with it);
Xcode resolves it on the first build, which needs network access.
iosApp/iosApp/AdsBridge.swift implements the Kotlin IosAdsBridge
interface on top of the SDK and is registered in iOSApp.init():
// iosApp/iosApp/iOSApp.swift
AdsBridgeRegistrationKt.registerAdsBridge(bridge: AdsBridge())
In-App Purchases with RevenueCat
A custom Compose paywall on a small purchases API and a premium entitlement,
wired to a Test Store key so purchases are simulated and nothing is charged.
What is included
With In-App Purchases (RevenueCat) on, the project gets two modules:
core/purchases:PurchasesKeys, thePurchasesManagerinterface (offer(),purchase(packageId),restore(),isPremium) and its RevenueCat implementation (Purchases KMP). App code never imports a RevenueCat type. RevenueCat is configured the first time Koin creates the manager (at launch, byFirstLaunchPaywall).feature/monetization: thepaywall/package (a plain Compose screen, its ViewModel, navigation andFirstLaunchPaywall). In the showcase it opens from Settings → Premium; in a blank project the start screen lists a Premium button.
Test Store key
revenueCatApiKey (PurchasesKeys.android.kt and
PurchasesKeys.ios.kt) is a RevenueCat Test Store key of the cmpose.dev
demo project. Purchases made with it are simulated by RevenueCat: no App Store or Play Store
account is needed and nothing is charged. While it is in use, the app logs a warning when
RevenueCat is configured, at launch: FirstLaunchPaywall in
MainScreen is the first to need PurchasesManager.
Release builds
RevenueCat's SDK closes a release build that is configured with a Test Store key. So a release
build that still has the Test Store key leaves RevenueCat unconfigured
(PurchasesManager.isAvailable is false, nobody is premium) and the
paywall shows a message that purchases are disabled in release builds instead. Debug builds
use the Test Store key as usual; a release build with your own platform keys configures
RevenueCat normally.
// core/purchases/.../PurchasesKeys.kt (revenueCatApiKey: PurchasesKeys.android.kt and PurchasesKeys.ios.kt)
object PurchasesKeys {
fun isTestStoreKey(apiKey: String): Boolean = apiKey.startsWith("test_")
// Debug builds take any key; release builds only a platform key
fun canConfigure(apiKey: String, isDebug: Boolean): Boolean = isDebug || !isTestStoreKey(apiKey)
}
Paywall and the premium entitlement
The paywall is a plain Compose screen (PaywallScreen.kt) on
PurchasesManager: the plans of the current offering with the store's
prices (annual selected, with its saving and monthly price), Continue,
Restore purchases, Not now and your privacy policy and terms
links (core/about). It is deliberately simple: redraw it in your app's design and
keep PaywallViewModel, which loads the offer, buys or restores and closes the screen
after a successful purchase or a restore that brings premium back.
The purchases API
offer() returns the current offering as plain models (PaywallOffer,
PaywallPackage: id, PackagePeriod, price, monthly price of annual and
weekly plans, annual saving in percent, free trial), or null without RevenueCat or
offline. purchase(packageId) and restore() return a
PurchaseResult: Success (isPremium follows),
Cancelled (show nothing) or Error(message) (a short text to show).
restore() without a purchase is Error("No purchases to restore").
// core/purchases/.../PurchasesManager.kt
interface PurchasesManager {
val isAvailable: Boolean // false: a release build with a Test Store key
val isPremium: StateFlow<Boolean?> // null until RevenueCat answers
suspend fun refresh()
suspend fun offer(): PaywallOffer?
suspend fun purchase(packageId: String): PurchaseResult
suspend fun restore(): PurchaseResult
}
// In a ViewModel of another Gradle module the dependency is @Provided
@KoinViewModel
internal class ProfileViewModel(
@Provided private val purchasesManager: PurchasesManager
) : CoreViewModel() {
val isPremium = purchasesManager.isPremium
}
The first-launch paywall
FirstLaunchPaywall() in MainScreen opens the paywall once, the first
time the app is ready (a blank project: at launch; the showcase: when the first user profile
opens), and never for a premium user or while the premium state is unknown. The user can
always close it. The flag paywall_first_shown stays on the device; clear the app's
data to see the paywall again. Pass ready = false until your own onboarding ends.
Offerings and packages in the dashboard
The paywall shows the offering marked current. Give it packages with RevenueCat's
standard identifiers ($rc_annual, $rc_monthly,
$rc_weekly, $rc_lifetime): their types decide the labels, the annual
saving and the monthly price. Attach every product to the premium entitlement;
a free trial is set on the product in the store (App Store Connect, Play Console).
Switch to your own project
- Create a project in RevenueCat and add your App Store and Play Store apps.
- Create the products, a
premiumentitlement (or change the name in code) and an offering marked current (see Offerings and packages). - Replace
revenueCatApiKey(PurchasesKeys.android.ktandPurchasesKeys.ios.kt) with your platform keys: the Apple key (appl_…) on iOS and the Google key (goog_…) on Android.
Replace the Test Store key before you ship: until then release builds keep
purchases disabled (see Release builds). The generated
README.md ("Monetization") lists the same steps.