- Use this repository as a template when creating a new repository for your project
- Run the rename script to rename the project:
./scripts/rename-project.sh(this handles Android and shared modules) - Rename iOS project - you can use the prepared script in
ios/scripts/rename.sh, for more info see the iOS readme
This repo contains our template for multiplatform mobile project. Both Android and iOS implementations are present with shared modules containing all common business logic organized in Clean architecture.
By default, everything up to UI is shared between platforms:
- Data layer (Repositories, Sources)
- Domain layer (Use Cases, Domain Models)
- Presentation layer (View Models, Compose Multiplatform UI)
Only navigation is native - each platform implements its own navigation layer to integrate shared screens into the native navigation structure.
The project contains a sample feature module (samplefeature) that demonstrates how to structure a complete feature with:
- Infrastructure services (networking)
- Data sources and repositories
- Domain use cases
- Shared view models
- Compose Multiplatform UI
- Native navigation integration
Clean Architecture + MVVM is used for its testability and ease of modularization. Code is divided into several layers:
- Data (
Service,Source,Repository) - Data access layer including HTTP services, data sources, and repositories - Domain (
UseCase,Model) - Business logic - Presentation (
ViewModel,UI) - Shared view models and Compose Multiplatform UI
Navigation is handled platform-specifically to integrate shared screens into native navigation structures.
Contains all base classes and common utilities needed across feature modules:
- Base View Models:
BaseViewModel,BaseScopedViewModelfor shared view models - Base Use Cases:
UseCaseResult,UseCaseResultNoParams,UseCaseFlowResultinterfaces - Base Models:
Result,ErrorResultfor error handling - Common utilities, error handling, and infrastructure providers
Authentication module providing:
- AuthService: Service for authentication operations (e.g., logout)
- TokenRefresher: Interface for token refresh functionality
- MockTokenRefresher: Mock implementation that returns a mock token (
⚠️ must be replaced with a real implementation)
⚠️ Important: TheMockTokenRefresherinAuthModule.ktis a placeholder implementation that returns a hardcoded "mockToken". You must replace it with a realTokenRefresherimplementation that uses your authentication service (FirebaseAuth, Auth0, etc.) to refresh tokens. See the Implementing Token Refresh section below.
Analytics module providing analytics tracking functionality across platforms.
Combines all feature modules and generates Framework for iOS. This is the main module imported in Android modules and provides the Koin initialization.
A complete example feature demonstrating:
- Service:
JokeService- Ktor-based networking service (data layer) - Data Source:
JokeSourceinterface andJokeSourceImpl- abstraction over services - Repository:
JokeRepositoryinterface andJokeRepositoryImpl- domain data access - Use Case:
GetRandomJokeUseCase- business logic - View Model:
SampleFeatureViewModelextendingBaseViewModel - UI:
SampleFeatureMainScreen- Compose Multiplatform screen
Main application module providing:
- Application entry point
- Root navigation setup
- Koin initialization
Shared Android code like common navigation components and utilities.
Native navigation integration for the sample feature, demonstrating how to integrate shared Compose Multiplatform screens into Android navigation.
- More info in the iOS readme
Note: The whole project relies heavily on dependency injection (Koin for shared/Android, Factory for iOS)
To create a new feature, follow the structure demonstrated in samplefeature:
- Create a new module in
shared/(e.g.,shared/myfeature) - Copy
build.gradle.ktsfromshared/samplefeatureand update thenamespace - Add the module to
settings.gradle.kts:include(":shared:myfeature") - Add dependency in
shared/umbrella/build.gradle.kts:- If you don't need to use the module from iOS code, use
commonMainImplementation:commonMainImplementation(project(":shared:myfeature")) - If you need to use the module from iOS code, use
commonMainApiand also add it toKmpConfig:Then add it to the export list incommonMainApi(project(":shared:myfeature"))build-logic/convention/src/main/kotlin/config/KmpConfig.kt:export(project(":shared:myfeature"))
- If you don't need to use the module from iOS code, use
- Add the DI module to
shared/umbrella/src/commonMain/kotlin/kmp/shared/umbrella/di/Module.kt:modules( baseModule, // ... other modules myFeatureModule, )
Follow the Clean Architecture layers:
shared/myfeature/src/commonMain/kotlin/kmp/shared/myfeature/
├── data/
│ ├── model/ # DTOs (Data Transfer Objects)
│ ├── service/ # HTTP services (Ktor clients)
│ ├── source/ # Data source interfaces and implementations
│ └── repository/ # Repository implementations
├── domain/
│ ├── model/ # Domain models
│ ├── repository/ # Repository interfaces
│ └── usecase/ # Use case interfaces and implementations
├── presentation/
│ ├── vm/ # View models (extend BaseViewModel)
│ └── ui/ # Compose Multiplatform screens
└── di/
└── Module.kt # Koin module
-
View Models: Extend
BaseViewModel<S, I, E>where:Sis your state (implementsVmState)Iis your intent (implementsVmIntent)Eis your event (implementsVmEvent)
-
Use Cases: Implement one of the following interfaces:
UseCaseResult<Params, T>orUseCaseResultNoParams<T>- for single-shot operations returningResult<T>UseCaseFlow<Params, T>orUseCaseFlowNoParams<T>- for operations returningFlow<T>UseCaseFlowResult<Params, T>orUseCaseFlowResultNoParams<T>- for operations returningFlow<Result<T>>
-
Repositories: Define interface in
domain/repository/, implement indata/repository/ -
Sources: Define interface in
data/source/, implement indata/source/impl/
-
Create
android/myfeaturemodule -
Copy
build.gradle.ktsfromandroid/samplefeature -
Add to
settings.gradle.kts:include(":android:myfeature") -
Add dependency in
android/app/build.gradle.kts:implementation(project(":android:myfeature")) -
Create navigation structure:
Create a FeatureGraph (e.g.,
MyFeatureGraph.kt):- Extend
FeatureGraphand define all screens (destinations) for this feature asDestinationobjects - Each
Destinationcan define navigation arguments using theargumentsproperty - For destinations with arguments, create an
Argsclass to extract them fromNavBackStackEntry - The graph can have a parent graph for nested navigation
import android.os.Bundle import androidx.navigation.NavType import androidx.navigation.navArgument import kmp.android.shared.navigation.Destination import kmp.android.shared.navigation.FeatureGraph object MyFeatureGraph : FeatureGraph(parent = null) { override val path = "myFeature" data object Main : Destination(this) { override val routeDefinition: String = "main" } data object Detail : Destination(this) { override val routeDefinition: String = "detail" private const val ItemIdArg = "itemId" override val arguments: List<NamedNavArgument> = listOf( navArgument(ItemIdArg) { type = NavType.StringType } ) internal class Args( val itemId: String = "", ) { constructor(arguments: Bundle?) : this( arguments?.getString(ItemIdArg) ?: "", ) } } }
Create a NavGraphBuilder extension (e.g.,
MyFeatureNavigation.kt):- This extension function should contain all navigation nodes (routes) for the feature
- It receives
NavHostControllerand creates navigation callbacks to pass down to route extensions - Call all route extension functions within the navigation block, passing callbacks as needed
fun NavGraphBuilder.myFeatureNavGraph( navHostController: NavHostController, ) { navigation( startDestination = MyFeatureGraph.Main.route, route = MyFeatureGraph.rootPath, ) { myFeatureMainRoute( onNavigateToDetail = { itemId -> navHostController.navigate(MyFeatureGraph.Detail(itemId)) } ) myFeatureDetailRoute( onBack = { navHostController.popBackStack() } ) // Add all routes for this feature } }
You can also create
NavControllerextension functions for navigation:import androidx.navigation.NavController internal fun NavController.navigateToMyFeatureDetail(itemId: String = "") { navigate(MyFeatureGraph.Detail(itemId)) }
Create Route composables (e.g.,
MyFeatureMain.kt):- Use the
composableDestinationextension (orbottomSheetDestination,dialogDestinationfor other types) - The route extension should only receive navigation callbacks as parameters (not
NavHostControllerdirectly) - Extract navigation arguments using the
Argsclass from the destination - The Route composable can accept navigation callbacks as parameters
- Handle view model state, events, and integrate your shared Compose Multiplatform screen
fun NavGraphBuilder.myFeatureDetailRoute( onBack: () -> Unit, ) { composableDestination( destination = MyFeatureGraph.Detail, ) { navBackStackEntry -> val args = MyFeatureGraph.Detail.Args(navBackStackEntry.arguments) MyFeatureDetailRoute( itemId = args.itemId, onBack = onBack, ) } } @Composable internal fun MyFeatureDetailRoute( itemId: String, viewModel: MyFeatureViewModel = koinViewModel(), onBack: () -> Unit, ) { val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(key1 = viewModel) { viewModel.events.collectLatest { event -> when (event) { is MyFeatureEvent.NavigateBack -> onBack() } } } MyFeatureDetailScreen( itemId = itemId, state = state, onIntent = { viewModel.onIntent(it) }, ) }
Add the nav graph to root navigation in
android/app/src/main/kotlin/kmp/android/ui/Root.kt:NavHost(navController, startDestination = ...) { myFeatureNavGraph(navController) // ... other nav graphs }
The
Destinationclass andNavGraphBuilderextensions (composableDestination,bottomSheetDestination,dialogDestination) are provided inandroid/sharedmodule and handle route construction with arguments automatically. See theDestinationandFeatureGraphabstract classes for details on how to define navigation arguments. - Extend
Add your feature's shared screen to iOS navigation. See the iOS README for details.
There are UI tests prepared in android/app/androidTest. You can take
inspiration and write tests for your own screens with the prepared structure and extensions.
The project uses Android build variants with two dimensions:
-
Build Type (debug/release):
debug- Development builds with debug signingrelease- Release builds with release signing
-
API Variant (alpha/production):
alpha- Connected to alpha/staging data sources (app name prefixed with "[A]")production- Connected to production data sources
Available build variants:
alphaDebug- Alpha API with debug buildalphaRelease- Alpha API with release buildproductionDebug- Production API with debug buildproductionRelease- Production API with release build
Build specific variants:
# Build alpha debug variant
./gradlew assembleAlphaDebug
# Build production release variant
./gradlew assembleProductionReleaseThe project uses Gradle convention plugins (located in build-logic/convention) to standardize build configuration across modules. These plugins automatically apply common configurations, dependencies, and settings.
android-application-compose- For Android application modules with Compose support- Applies Android application plugin, Compose compiler, and Compose dependencies
- Configures build variants (alpha/production), signing, and Twine string generation
android-application-core- For Android application modules without Compose- Same as above but without Compose configuration
android-library-compose- For Android library modules with Compose support- Applies Android library plugin and Compose dependencies
android-library-core- For Android library modules without Compose- Applies Android library plugin with standard Android configuration
kmp-library-core- For KMP library modules- Configures Kotlin Multiplatform with Android and iOS targets
- Applies Moko Resources for shared string resources
- Sets up common dependencies and test configuration
kmp-library-compose- For KMP library modules with Compose Multiplatform- Extends
kmp-library-coreand adds Compose Multiplatform support - Configures Compose compiler and dependencies
- Extends
kmp-framework-library- For KMP modules that generate iOS frameworks- Extends
kmp-library-coreand configures iOS framework generation - Used by
:shared:umbrellamodule to generate the Framework for iOS
- Extends
Simply apply the convention plugin in your module's build.gradle.kts:
plugins {
alias(libs.plugins.mateeStarter.android.application.compose)
// or
alias(libs.plugins.mateeStarter.kmp.library.compose)
}The plugin IDs are defined in gradle/libs.versions.toml and can be customized after renaming the project.
Koin supports Kotlin Multiplatform and it's pure Kotlin project. Each module (including all Android feature modules) has it's own Koin module. All modules (including common module) are put together inside platform specific code where Koin is initialized.
We are using DI library Factory.
Accessing network is usually the most used IO operation for mobile apps so Ktor was used for it's simple and extensible API and because it's multiplatform capable with different engines for each platform.
The project includes secure token storage for authentication tokens. The AuthProvider interface and its implementation (AuthProviderImpl) handle token storage and refresh logic.
Tokens are stored securely using platform-specific secure storage mechanisms:
- Android: Tokens are encrypted using
SecureSharedPreferences, which uses Android KeyStore with AES/GCM encryption. The encryption key is stored in the hardware-backed Android KeyStore, ensuring tokens are protected at rest. - iOS: Tokens are stored in the iOS Keychain.
The AuthProvider interface provides:
token: String?- Property to get/set the current authentication tokenrefreshToken(): String?- Suspending function to refresh the token when it expires
You should use FirebaseAuth or any other authentication service (e.g., Auth0, AWS Cognito, custom backend) to handle user authentication and receive tokens. Once you receive the authentication token from your authentication service, store it in the AuthProvider.
The AuthProvider is responsible for securely storing and managing authentication tokens. It's configured in the dependency injection modules:
- Android: Configured in
BaseModule.android.ktusingSharedPreferencesFactorywithSharedPreferencesType.ENCRYPTEDto ensure secure storage. - iOS: Configured in
BaseModule.ios.ktusingKeychainFactoryto store tokens in the iOS Keychain.
The HttpClient is automatically configured to use AuthProvider for bearer token authentication, including automatic token refresh when requests fail with 401 Unauthorized.
⚠️ Important: The project includes aMockTokenRefresherin:shared:authmodule that returns a hardcoded mock token. This is only for development/testing purposes and must be replaced with a real implementation before production.
The AuthProviderImpl requires a TokenRefresher implementation to handle token refresh. You must replace the MockTokenRefresher with a real implementation:
1. Create your TokenRefresher implementation:
Use your authentication service (FirebaseAuth, Auth0, etc.) to refresh the token.
2. Replace MockTokenRefresher in AuthModule.kt:
Update shared/auth/src/commonMain/kotlin/kmp/shared/auth/di/AuthModule.kt to use your implementation:
1. Implement the TokenRefresher interface:
Use your authentication service (FirebaseAuth, Auth0, etc.) to refresh the token.
2. Provide the TokenRefresher in your DI modules:
The AuthProviderImpl will automatically use the provided TokenRefresher when refreshToken() is called, ensuring that concurrent refresh requests are deduplicated and handled efficiently. After refreshing, the new token will be automatically stored in AuthProvider and used for subsequent API requests.
All strings in the application are localized and shared with the iOS team
via Twine. Strings are stored in the twine/strings.txt file.
TwinePlugin then generates appropriate strings.xml files from the mentioned strings.txt file.
When modifying strings.txt it is required to comply with the specified syntax and to pull/push all
the changes frequently
Error messages are shared via Moko Resources, so
that we can use the strings in the shared code and avoid duplicities when converting errors to
string messages. Error strings are stored in the twine/errors.txt file. Gradle task
generateErrorsTwine first generates strings.xml files from errors.txt and then gradle task
generateMRCommonMain generates MR class that can be used in the common code.
We use Compose Multiplatform for both Android and iOS. The UI is written once in shared modules and works on both platforms.
For platform-specific UI components that need native implementations, we use expect/actual declarations:
- Define
expectdeclarations incommonMainfor platform-specific views - Implement
actualdeclarations in platform-specific source sets (androidMain/iosMain)
On iOS, expect views can be implemented via:
- CInterop - for C-based native libraries
- Swift - using either UIKit or SwiftUI, which are then integrated into Compose via Factory pattern
The Factory pattern allows Swift implementations to be provided to Compose Multiplatform code, enabling seamless integration of native iOS UI components when needed.
- More info in the iOS readme
