●SIRI — WWDC 2026 confirms the revamped Siri runs on a Google Gemini model, though it won't ship in the EU at iOS 27 due to the DMA●FLASH3.5 — Gemini 3.5 Flash is now GA, the top Flash model for sustained frontier performance on agentic and coding tasks●IMAGE-GA — Gemini 3.1 Flash Image and 3.1 Pro Image are GA as native visual models; the preview versions shut down Jun 25●MANAGED-AGENTS — Managed Agents launch in public preview in the Gemini API, running autonomous agents in Google-hosted isolated Linux sandboxes●FILE-SEARCH — File Search now supports multimodal search, with native image embedding and retrieval via gemini-embedding-2●DEPRECATION — gemini-3.1-flash-image-preview and gemini-3-pro-image-preview shut down Jun 25 — migrate to the GA models soon●SIRI — WWDC 2026 confirms the revamped Siri runs on a Google Gemini model, though it won't ship in the EU at iOS 27 due to the DMA●FLASH3.5 — Gemini 3.5 Flash is now GA, the top Flash model for sustained frontier performance on agentic and coding tasks●IMAGE-GA — Gemini 3.1 Flash Image and 3.1 Pro Image are GA as native visual models; the preview versions shut down Jun 25●MANAGED-AGENTS — Managed Agents launch in public preview in the Gemini API, running autonomous agents in Google-hosted isolated Linux sandboxes●FILE-SEARCH — File Search now supports multimodal search, with native image embedding and retrieval via gemini-embedding-2●DEPRECATION — gemini-3.1-flash-image-preview and gemini-3-pro-image-preview shut down Jun 25 — migrate to the GA models soon
Gemini API × Kotlin Multiplatform: to Shared AI Logic for iOS and Android
A complete guide to integrating Gemini API with Kotlin Multiplatform (KMP) for shared AI logic across iOS and Android. Covers Gradle setup, Ktor HTTP client, SwiftUI/Compose UI, secure API key management, multimodal image analysis, and production deployment.
When you're building AI features for both iOS and Android, you end up writing the same logic twice — once in Swift, once in Kotlin. What I found more painful than the duplicate code itself was dealing with bug fixes and API changes across two platforms simultaneously.
Kotlin Multiplatform (KMP) changes that equation fundamentally. The Gemini API call logic, response parsing, error handling, and state management — write it once in a shared module, and both iOS and Android run the same code. Native UI experience intact.
This guide walks through integrating Gemini API into a KMP project, from initial setup to production deployment, with working code you can run today.
Why KMP + Gemini, and What to Watch Out For
Why Not Flutter or React Native?
Flutter and React Native are both viable cross-platform options. But when it comes to serious AI feature integration, each has meaningful limitations.
Flutter's Dart SDK for Gemini is more limited compared to the official SDKs (Python, JS, Swift, Kotlin), and Google tends to add features to those first. React Native gives you access to Gemini's JS SDK, but native camera integration and image processing performance can be a bottleneck.
KMP's decisive advantage is that you can use Ktor (Kotlin's KMP-compatible HTTP client) in the shared module to call Gemini's REST API from both iOS and Android. You get native UI with SwiftUI and Jetpack Compose while keeping the AI call logic in one place.
When KMP + Gemini Is the Right Fit
Text generation, chat, and summarization via network calls to Gemini API
Same business logic across iOS and Android, only UI differs
Future plans to expand to Desktop (macOS, Windows) or Web
If you need on-device inference (Gemini Nano) or deep integration with platform-specific ML frameworks, native implementations will serve you better.
Project Setup
Prerequisites
Android Studio Giraffe or later (with KMP plugin support)
Create a new KMP project using Android Studio's "New Project → Kotlin Multiplatform App" wizard. Then update shared/build.gradle.kts to add Gemini API dependencies:
Why Ktor instead of the official SDK? Google's official Kotlin SDK (com.google.ai.client.generativeai) targets Android/JVM only and can't be used directly in a shared module that includes iOS targets. Using Ktor with Gemini's REST API directly gives you true cross-platform support — iOS, Android, and beyond.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Developers stuck on KMP + Gemini API setup can get working Gradle configuration, HTTP client patterns, and coroutine design today
✦Learn to separate iOS (SwiftUI) and Android (Jetpack Compose) UIs from shared AI logic with working code covering both platforms in one codebase
✦Implement secure API key management, rate limiting with retry logic, and multimodal image analysis to ship a production-quality AI-powered cross-platform app
Secure payment via Stripe · Cancel anytime
Shared Module AI Logic Design
Isolate API Dependencies with the Repository Pattern
When centralizing AI logic in a shared module, the most important design decision is keeping API implementation details separate from business logic. Define the interface first, then implement it.
// shared/src/commonMain/kotlin/ai/GeminiRepository.ktinterface GeminiRepository { suspend fun generateText(prompt: String): Result<String> suspend fun chat( history: List<ChatMessage>, message: String ): Result<String> suspend fun analyzeImage( imageBytes: ByteArray, prompt: String ): Result<String>}data class ChatMessage( val role: MessageRole, val content: String)enum class MessageRole { USER, MODEL }
This lets you swap in a mock implementation for testing, and swapping the underlying model (say, to a different provider) doesn't require touching upper layers.
Implementation: Ktor + Gemini REST API
// shared/src/commonMain/kotlin/ai/GeminiRepositoryImpl.ktimport io.ktor.client.*import io.ktor.client.call.*import io.ktor.client.plugins.contentnegotiation.*import io.ktor.client.request.*import io.ktor.http.*import io.ktor.serialization.kotlinx.json.*import kotlinx.serialization.Serializableimport kotlinx.serialization.json.Jsonclass GeminiRepositoryImpl( private val apiKey: String, private val modelName: String = "gemini-2.5-flash") : GeminiRepository { private val baseUrl = "https://generativelanguage.googleapis.com/v1beta" private val client = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true isLenient = true }) } } override suspend fun generateText(prompt: String): Result<String> = runCatching { val response = client.post( "$baseUrl/models/$modelName:generateContent?key=$apiKey" ) { contentType(ContentType.Application.Json) setBody( GenerateRequest( contents = listOf( Content(parts = listOf(Part(text = prompt))) ) ) ) } val result: GenerateResponse = response.body() result.candidates.firstOrNull()?.content?.parts ?.firstOrNull()?.text ?: error("Empty response from API") } override suspend fun chat( history: List<ChatMessage>, message: String ): Result<String> = runCatching { val contents = history.map { msg -> Content( role = msg.role.name.lowercase(), parts = listOf(Part(text = msg.content)) ) } + Content( role = "user", parts = listOf(Part(text = message)) ) val response = client.post( "$baseUrl/models/$modelName:generateContent?key=$apiKey" ) { contentType(ContentType.Application.Json) setBody(GenerateRequest(contents = contents)) } val result: GenerateResponse = response.body() result.candidates.firstOrNull()?.content?.parts ?.firstOrNull()?.text ?: error("Empty chat response") } override suspend fun analyzeImage( imageBytes: ByteArray, prompt: String ): Result<String> = runCatching { val base64 = imageBytes.encodeBase64String() val response = client.post( "$baseUrl/models/$modelName:generateContent?key=$apiKey" ) { contentType(ContentType.Application.Json) setBody( GenerateRequest( contents = listOf( Content( parts = listOf( Part( inlineData = InlineData( mimeType = "image/jpeg", data = base64 ) ), Part(text = prompt) ) ) ) ) ) } val result: GenerateResponse = response.body() result.candidates.firstOrNull()?.content?.parts ?.firstOrNull()?.text ?: error("Empty image analysis response") }}// Serialization data classes@Serializabledata class GenerateRequest(val contents: List<Content>)@Serializabledata class Content( val role: String = "user", val parts: List<Part>)@Serializabledata class Part( val text: String? = null, val inlineData: InlineData? = null)@Serializabledata class InlineData(val mimeType: String, val data: String)@Serializabledata class GenerateResponse( val candidates: List<Candidate> = emptyList())@Serializabledata class Candidate(val content: Content)
Secure API Key Management
The most common mistake in KMP apps is hardcoding the API key. Once it's in your GitHub history, it's effectively public — and Google's Secret Scanner will flag it and potentially disable the key.
// shared/src/commonMain/kotlin/security/SecureStorage.kt// expect/actual lets each platform use its own secure storage mechanismexpect class SecureStorage { fun getString(key: String): String? fun putString(key: String, value: String)}
// shared/src/androidMain/kotlin/security/SecureStorage.android.ktimport android.content.Contextimport androidx.security.crypto.EncryptedSharedPreferencesimport androidx.security.crypto.MasterKeyactual class SecureStorage(private val context: Context) { private val prefs by lazy { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() EncryptedSharedPreferences.create( context, "secure_prefs", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) } actual fun getString(key: String): String? = prefs.getString(key, null) actual fun putString(key: String, value: String) { prefs.edit().putString(key, value).apply() }}
// iosApp/iosApp/Security/SecureStorageIOS.swift// iOS side uses Keychain Services (implemented in Swift outside the KMP framework)import Foundationimport Securityclass SecureStorageIOS { func getString(key: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var item: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } return String(data: data, encoding: .utf8) } func putString(key: String, value: String) { guard let data = value.data(using: .utf8) else { return } SecItemDelete([ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key ] as CFDictionary) SecItemAdd([ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key, kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly ] as CFDictionary, nil) }}
Android uses EncryptedSharedPreferences, iOS uses Keychain Services — both inaccessible outside the app sandbox. On first launch, transfer the key from BuildConfig to encrypted storage and never read BuildConfig again.
Android UI: Jetpack Compose
Connect the shared ViewModel and Repository to Compose UI:
// shared/src/commonMain/kotlin/ui/ChatViewModel.ktimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.StateFlowimport kotlinx.coroutines.flow.updateimport kotlinx.coroutines.launchclass ChatViewModel( private val repository: GeminiRepository, private val scope: CoroutineScope = CoroutineScope(Dispatchers.Main)) { private val _state = MutableStateFlow(ChatState()) val state: StateFlow<ChatState> = _state fun onInputChanged(text: String) { _state.update { it.copy(inputText = text) } } fun sendMessage() { val message = _state.value.inputText.trim() if (message.isEmpty() || _state.value.isLoading) return _state.update { it.copy( messages = it.messages + ChatMessage(MessageRole.USER, message), inputText = "", isLoading = true, error = null )} scope.launch { repository.chat( history = _state.value.messages.dropLast(1), message = message ).fold( onSuccess = { reply -> _state.update { it.copy( messages = it.messages + ChatMessage(MessageRole.MODEL, reply), isLoading = false )} }, onFailure = { e -> _state.update { it.copy( isLoading = false, error = "Error: ${e.message}" )} } ) } }}data class ChatState( val messages: List<ChatMessage> = emptyList(), val inputText: String = "", val isLoading: Boolean = false, val error: String? = null)
// androidApp/src/main/kotlin/com/yourapp/ui/ChatScreen.kt@Composablefun ChatScreen() { val repository = remember { GeminiRepositoryImpl(apiKey = BuildConfig.GEMINI_API_KEY) } val viewModel = remember { ChatViewModel(repository) } val state by viewModel.state.collectAsState() val listState = rememberLazyListState() LaunchedEffect(state.messages.size) { if (state.messages.isNotEmpty()) { listState.animateScrollToItem(state.messages.lastIndex) } } Scaffold( bottomBar = { Row( modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { OutlinedTextField( value = state.inputText, onValueChange = viewModel::onInputChanged, modifier = Modifier.weight(1f), placeholder = { Text("Type a message...") }, enabled = \!state.isLoading ) Spacer(Modifier.width(8.dp)) IconButton( onClick = viewModel::sendMessage, enabled = \!state.isLoading && state.inputText.isNotBlank() ) { Icon(Icons.Default.Send, contentDescription = "Send") } } } ) { padding -> LazyColumn( state = listState, contentPadding = padding, modifier = Modifier.fillMaxSize() ) { items(state.messages) { message -> val isUser = message.role == MessageRole.USER Row( modifier = Modifier.fillMaxWidth().padding(8.dp), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start ) { Surface( color = if (isUser) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(12.dp), modifier = Modifier.widthIn(max = 280.dp) ) { Text( text = message.content, modifier = Modifier.padding(12.dp), color = if (isUser) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant ) } } } if (state.isLoading) { item { Row(Modifier.padding(8.dp)) { CircularProgressIndicator(Modifier.size(24.dp)) Spacer(Modifier.width(8.dp)) Text("Thinking...") } } } } }}
iOS UI: SwiftUI Implementation
After embedding the KMP framework (Shared.xcframework) in Xcode, you'll need a wrapper to bridge Kotlin's StateFlow to Swift's @Published properties.
// iosApp/iosApp/ViewModels/ChatViewModelWrapper.swiftimport SwiftUIimport Shared@MainActorclass ChatViewModelWrapper: ObservableObject { private let viewModel: ChatViewModel @Published var messages: [ChatMessage] = [] @Published var inputText: String = "" { didSet { viewModel.onInputChanged(text: inputText) } } @Published var isLoading: Bool = false @Published var error: String? = nil private var observationTask: Task<Void, Never>? init() { let repository = GeminiRepositoryImpl( apiKey: SecureStorageIOS().getString(key: "gemini_api_key") ?? "", modelName: "gemini-2.5-flash" ) self.viewModel = ChatViewModel(repository: repository) startObserving() } private func startObserving() { observationTask = Task { @MainActor in for await state in viewModel.state { self.messages = state.messages self.isLoading = state.isLoading self.error = state.error if state.inputText \!= self.inputText { self.inputText = state.inputText } } } } func sendMessage() { viewModel.sendMessage() } deinit { observationTask?.cancel() }}
Here are the pitfalls I've hit building real KMP + Gemini apps, with Before/After patterns.
Mistake 1: Hardcoding the API Key in Source
// ❌ The key ends up in your APK/IPA, readable via reverse engineeringobject Config { const val GEMINI_API_KEY = "YOUR_ACTUAL_KEY_HERE" // never do this}
// ✅ Use local.properties → BuildConfig → encrypted storage pipeline// local.properties (already in .gitignore): GEMINI_API_KEY=YOUR_GEMINI_API_KEY// build.gradle.kts:buildConfigField("String", "GEMINI_API_KEY", "\"${localProperties.getProperty("GEMINI_API_KEY", "")}\"")// On first launch, store in EncryptedSharedPreferences / Keychain// and never read from BuildConfig again
Mistake 2: Using a Debug Framework Build in Release
# ❌ Default Gradle task produces debug build — binary 2-3x larger./gradlew assembleXCFramework# ✅ Explicitly use RELEASE mode for App Store submissions./gradlew assembleXCFramework -Pkotlin.native.binary.mode=RELEASE
Debug frameworks inflate binary size and can trigger App Store rejection. Always use release builds for production.
Mistake 3: Forgetting to Cancel the Swift Task
// ❌ Without storing the task, it continues running after the view is dismissedfunc startObserving() { Task { for await state in viewModel.state { ... } }}// ✅ Store the task and cancel on deinitprivate var observationTask: Task<Void, Never>?func startObserving() { observationTask = Task { @MainActor in for await state in viewModel.state { ... } }}deinit { observationTask?.cancel()}
Mistake 4: Not Handling Rate Limit Errors
The free Gemini API tier allows 15 requests per minute (gemini-2.5-flash). Rapid consecutive sends produce 429 errors that, if unhandled, make the app appear frozen.
// ✅ Add exponential backoff retry to the shared modulesuspend fun <T> withRetry( maxAttempts: Int = 3, initialDelayMs: Long = 1000L, block: suspend () -> T): T { var delayMs = initialDelayMs repeat(maxAttempts - 1) { try { return block() } catch (e: Exception) { if (e.message?.contains("429") == true || e.message?.contains("RESOURCE_EXHAUSTED") == true) { kotlinx.coroutines.delay(delayMs) delayMs = (delayMs * 2).coerceAtMost(30_000L) } else { throw e // don't retry non-rate-limit errors } } } return block() // final attempt — let exception propagate on failure}// Usage inside GeminiRepositoryImpl:override suspend fun generateText(prompt: String): Result<String> = runCatching { withRetry { /* API call */ } }
Mistake 5: Not Triggering Framework Rebuild in Xcode
# When you change Kotlin shared code, Xcode doesn't notice automatically.# Add this to Xcode's "Run Script" build phase:cd "${SRCROOT}/../"./gradlew :shared:embedAndSignAppleFrameworkForXcode
Without this, you'll end up debugging "changes that have no effect" — it's the old compiled framework running.
Multimodal: Camera Image Analysis
Use the shared analyzeImage() to process photos taken with the device camera:
// androidApp: camera capture → ByteArray → Gemini analysis@Composablefun ImageAnalysisScreen(repository: GeminiRepository) { var description by remember { mutableStateOf("") } var isLoading by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() val launcher = rememberLauncherForActivityResult( ActivityResultContracts.TakePicturePreview() ) { bitmap -> bitmap?.let { val bytes = ByteArrayOutputStream().apply { it.compress(Bitmap.CompressFormat.JPEG, 85, this) }.toByteArray() scope.launch { isLoading = true repository.analyzeImage( imageBytes = bytes, prompt = "Describe this image in detail. List the main subjects " + "and describe the overall mood and context." ).fold( onSuccess = { result -> description = result isLoading = false }, onFailure = { e -> description = "Error: ${e.message}" isLoading = false } ) } } } Column(Modifier.fillMaxSize().padding(16.dp)) { if (description.isNotEmpty()) { Card(Modifier.fillMaxWidth().weight(1f)) { Text( text = description, modifier = Modifier .padding(16.dp) .verticalScroll(rememberScrollState()) ) } Spacer(Modifier.height(16.dp)) } else { Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { Text( "Take a photo to analyze it with AI", color = MaterialTheme.colorScheme.onSurfaceVariant ) } } Button( onClick = { launcher.launch(null) }, enabled = \!isLoading, modifier = Modifier.fillMaxWidth() ) { if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(20.dp), color = MaterialTheme.colorScheme.onPrimary ) Spacer(Modifier.width(8.dp)) Text("Analyzing...") } else { Icon(Icons.Default.CameraAlt, contentDescription = null) Spacer(Modifier.width(8.dp)) Text("Take Photo and Analyze") } } }}
Pre-Launch Checklist
Before submitting to App Store / Google Play:
Security:
API key is not present in source, APK, or IPA (verify with strings command or jadx)
HTTPS-only communication (iOS ATS, Android Network Security Config)
Privacy policy discloses that user data is sent to Gemini API
Performance:
HTTP timeout configured (set HttpTimeout in Ktor to ~30 seconds)
Proper error messages for network failures
Images resized before sending (Gemini recommends max 1024px on longest side)
Max prompt length limit (prevents accidental massive payloads)
Quota alerts configured in Google AI Studio
App Store Requirements:
Disclose AI-generated content to users where required
Verify AI content restrictions for apps in children's categories
INTERNET permission declared in AndroidManifest.xml
Realistic Cost Estimates
gemini-2.5-flash pricing (as a rough guide):
Text input: $0.075 / 1M tokens
Text output: $0.30 / 1M tokens
Image input: roughly $0.001–$0.003 per image depending on resolution
A chat app where users send 20 messages per day would cost roughly $0.003–$0.01 per user per month. At 1,000 monthly active users, you're looking at $3–$10/month — manageable for an indie app.
Image-heavy features consume 300–1,500 tokens per analysis call, so estimate that separately before committing. The free tier (15 requests/minute) is enough for development and early launch; paid plans unlock significantly higher quotas.
Adopting KMP means one codebase to maintain for both platforms. Start with a simple AI feature — text summarization, translation, or basic chat — observe how users engage, then expand from there. The Gemini API and KMP combination is genuinely viable for indie developers shipping production AI apps in 2026.
Structuring the Shared ViewModel for Testability
One area where KMP + Gemini projects often accumulate tech debt early is ViewModel testability. Because the ViewModel lives in the shared module, you can write tests that run on both JVM (fast) and native targets. Here's how to structure it for that.
// shared/src/commonTest/kotlin/ui/ChatViewModelTest.ktimport kotlinx.coroutines.Dispatchersimport kotlinx.coroutines.ExperimentalCoroutinesApiimport kotlinx.coroutines.test.StandardTestDispatcherimport kotlinx.coroutines.test.resetMainimport kotlinx.coroutines.test.runTestimport kotlinx.coroutines.test.setMainimport kotlin.test.AfterTestimport kotlin.test.BeforeTestimport kotlin.test.Testimport kotlin.test.assertEqualsimport kotlin.test.assertFalseimport kotlin.test.assertTrue// Mock implementation for testingclass FakeGeminiRepository : GeminiRepository { var nextResponse: Result<String> = Result.success("Test response") var callCount = 0 override suspend fun generateText(prompt: String): Result<String> { callCount++ return nextResponse } override suspend fun chat( history: List<ChatMessage>, message: String ): Result<String> { callCount++ return nextResponse } override suspend fun analyzeImage( imageBytes: ByteArray, prompt: String ): Result<String> { callCount++ return nextResponse }}@OptIn(ExperimentalCoroutinesApi::class)class ChatViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var fakeRepository: FakeGeminiRepository private lateinit var viewModel: ChatViewModel @BeforeTest fun setUp() { Dispatchers.setMain(testDispatcher) fakeRepository = FakeGeminiRepository() viewModel = ChatViewModel( repository = fakeRepository, scope = CoroutineScope(testDispatcher) ) } @AfterTest fun tearDown() { Dispatchers.resetMain() } @Test fun sendMessage_addsUserMessageImmediately() = runTest { viewModel.onInputChanged("Hello AI") viewModel.sendMessage() val state = viewModel.state.value assertTrue( state.messages.any { it.role == MessageRole.USER && it.content == "Hello AI" } ) assertTrue(state.isLoading) } @Test fun sendMessage_onSuccess_addsModelReply() = runTest { fakeRepository.nextResponse = Result.success("Hi there\!") viewModel.onInputChanged("Hello") viewModel.sendMessage() testDispatcher.scheduler.advanceUntilIdle() val state = viewModel.state.value assertEquals(2, state.messages.size) assertEquals("Hi there\!", state.messages.last().content) assertFalse(state.isLoading) } @Test fun sendMessage_onFailure_setsError() = runTest { fakeRepository.nextResponse = Result.failure(Exception("Network error")) viewModel.onInputChanged("Hello") viewModel.sendMessage() testDispatcher.scheduler.advanceUntilIdle() val state = viewModel.state.value assertFalse(state.isLoading) assertTrue(state.error?.contains("Network error") == true) }}
Running these tests with ./gradlew :shared:testDebugUnitTest covers the business logic on JVM without needing emulators. You can also configure them to run on iOS simulator with ./gradlew :shared:iosSimulatorArm64Test, though it's slower.
The key principle: keep GeminiRepository as an interface, and the ViewModel becomes trivially testable with any fake implementation. This discipline pays off as your feature set grows.
Extending to Desktop and Web
One of KMP's underappreciated strengths is that once you've written the shared AI module, extending to other targets is mostly a UI problem, not an AI logic problem.
For macOS Desktop (using Compose Multiplatform):
// desktopApp/src/main/kotlin/Main.ktimport androidx.compose.ui.window.Windowimport androidx.compose.ui.window.applicationfun main() = application { Window(onCloseRequest = ::exitApplication, title = "AI Assistant") { // Reuse the same ChatScreen composable from androidApp // with the same ChatViewModel from shared module val repository = GeminiRepositoryImpl( apiKey = System.getenv("GEMINI_API_KEY") ?: "" ) ChatScreen(viewModel = ChatViewModel(repository)) }}
For Web (Kotlin/Wasm or Kotlin/JS), the shared module's pure Kotlin logic works as-is, since Ktor has a browser-compatible engine (ktor-client-js). Add it to your web source set:
// In shared/build.gradle.ktswasmJs { browser()}sourceSets { wasmJsMain.dependencies { implementation("io.ktor:ktor-client-js:2.3.12") }}
You'll write a React or Compose HTML frontend, but the GeminiRepository interface stays exactly the same. The AI logic you've already tested in the shared module simply works.
This is what KMP's value proposition actually looks like in practice: the expensive part (AI integration, business logic, testing) done once, the cheap part (adapting UI per platform) done per target.
Monitoring and Observability in Production
Shipping an AI feature is the beginning, not the end. You need visibility into how Gemini API calls perform in production.
A simple approach for both platforms: add a logging layer to the repository.
// shared/src/commonMain/kotlin/ai/LoggingGeminiRepository.ktclass LoggingGeminiRepository( private val delegate: GeminiRepository, private val logger: AppLogger // expect/actual for platform-specific logging) : GeminiRepository { override suspend fun generateText(prompt: String): Result<String> { val start = currentTimeMillis() val result = delegate.generateText(prompt) val elapsed = currentTimeMillis() - start result.fold( onSuccess = { response -> logger.info( "generateText success", mapOf( "prompt_chars" to prompt.length.toString(), "response_chars" to response.length.toString(), "elapsed_ms" to elapsed.toString() ) ) }, onFailure = { e -> logger.error( "generateText failure", mapOf( "error" to (e.message ?: "unknown"), "elapsed_ms" to elapsed.toString() ) ) } ) return result } // Similar wrappers for chat() and analyzeImage() override suspend fun chat(history: List<ChatMessage>, message: String) = delegate.chat(history, message) override suspend fun analyzeImage(imageBytes: ByteArray, prompt: String) = delegate.analyzeImage(imageBytes, prompt)}
Then compose it when initializing:
val baseRepository = GeminiRepositoryImpl(apiKey = apiKey)val repository = LoggingGeminiRepository( delegate = baseRepository, logger = PlatformLogger() // actual implementation per platform)
This decorator pattern keeps the monitoring concern completely separate from the API logic. You can swap in a crash reporting logger (Firebase Crashlytics, Sentry) without touching GeminiRepositoryImpl.
Track these metrics from day one: average response latency, error rate by error type, prompt length distribution, and daily API call volume. They'll tell you whether your retry logic is actually firing in production and whether users are hitting rate limits.
Where to Go from Here
With this foundation in place, several natural next steps open up:
Streaming responses: Gemini's REST API supports Server-Sent Events for streaming. Implement it in GeminiRepositoryImpl and expose it as a Flow<String> from the repository interface — the ViewModel and UI don't need to change their contract.
Conversation memory: Store ChatMessage history to a local database (SQLDelight works great in KMP) so conversations persist across app launches. The ChatViewModel already receives history; persisting it is a data layer concern separate from AI logic.
Model selection UI: Let users switch between gemini-2.5-flash (fast, cost-effective) and gemini-2.5-pro (more capable) for different tasks. Expose modelName as a configurable parameter through the repository, or create separate repository instances.
On-device fallback: For simple tasks, integrate Gemini Nano (Android) or Core ML (iOS) as a local fallback when the network is unavailable. The repository interface abstracts this routing decision cleanly.
KMP and Gemini API are both mature enough to build on today. The combination gives indie developers a real shot at shipping a high-quality AI app across platforms without a team.
A Note on Kotlin Multiplatform Stability
A question that comes up often: "Is KMP production-ready?" As of 2026, the answer is yes for the core use case described here — shared business logic, network calls, and state management. The Kotlin team declared the KMP toolchain stable in late 2023, and several major apps (including apps by Google itself) ship KMP code in production.
The main caveat: the Compose Multiplatform UI layer (for sharing UI code across platforms) is still maturing on desktop and web targets. But that's not what we're using here — we're using native SwiftUI on iOS and native Jetpack Compose on Android, with only the non-UI AI logic shared. That specific combination is rock-solid.
If you're on the fence about adopting KMP for a new project, the clearest signal is your team's existing Kotlin investment. If you're already writing Android Kotlin, adding a shared module for AI logic is a low-risk, high-leverage move. If your iOS team is Swift-first and hasn't touched Kotlin, expect a learning curve on the shared module side — but the Gemini integration itself remains in Kotlin, and the Swift UI code stays pure Swift.
For solo developers shipping to both platforms, KMP + Gemini is one of the most cost-effective stacks available today for AI-powered cross-platform apps. One codebase for the hard AI logic, native UI for the parts users actually see.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.