Back to Insights
Mobile Development14 min read

From Android Native to Kotlin Multiplatform: Refactoring SDAI for iOS

A behind-the-scenes engineering story of how SDAI moved from a mature Android app to a shared Kotlin Multiplatform product without sacrificing provider depth, local generation, or the existing Android experience.

June 17, 2026
by Dmytro Moroz
#sdai#kotlin#kmp#ios#android#architecture#ai-image-generation

SDAI started as a practical Android answer to a very simple problem: image generation was moving fast, but the mobile workflow around it felt fragmented. If you had an AUTOMATIC1111 server, a SwarmUI setup, a hosted provider key, or a local model experiment, you still needed a clean place on your phone to configure it, generate, review, save, and keep moving.

That was the original idea behind Stable Diffusion AI, later shortened to SDAI: not another model, not another web UI, but a mobile client that respected provider choice.

The Android-first years

The first public Android line appeared in 2023. Over the 0.x releases, SDAI became more than a thin API wrapper. It grew into a full mobile product: self-hosted Stable Diffusion servers, AI Horde, Hugging Face, OpenAI Images, Stability AI, local ONNX generation, Google AI MediaPipe generation, prompt controls, model selectors, LoRA and embeddings discovery, gallery details, native save and share flows, Play Store builds, F-Droid builds, and nightly channels.

By the 0.6.x line, the Android version had reached the shape most long-running indie products eventually reach: useful, battle-tested, and also heavy with history. It had solved enough real problems to prove the idea. It also carried enough platform-specific assumptions that "just build an iOS app too" would have been the wrong answer.

Then development slowed down for a while. That pause mattered. Coming back to a project after time away is a good stress test for architecture. If the boundaries are unclear, every feature feels like archaeology. If the boundaries are clean, the project quietly tells you where the next move belongs.

The new goal

The next milestone was bigger than "ship the same app on iOS". The goal was to make SDAI a real cross-platform product:

  • release a native iOS app;
  • keep the existing Android product alive;
  • avoid maintaining two separate business-logic codebases;
  • support the same remote provider model across platforms;
  • add local generation where each platform has a credible native runtime;
  • keep Android local generation instead of flattening the product to the lowest common denominator.

That last point shaped the refactor. Kotlin Multiplatform was not used as a magic button to share everything. It was used as a way to share the parts that were already product logic, while making platform differences explicit where they belonged.

Why the old architecture helped

SDAI was not a single Android screen with networking mixed into ViewModels. The Android version already had a clean multi-module structure around domain use cases, repositories, data sources, provider modules, local storage, presentation state, and platform services.

That mattered. The KMP refactor could move code into commonMain because much of the code already behaved like shared code. The project did not need a conceptual rewrite first. It needed a careful extraction of Android dependencies from places where they had leaked into otherwise portable logic.

The resulting structure is easy to read from the module list: domain, data, network, storage, presentation, core, and focused feature modules such as auth, work, onnx, mediapipe, sdxl, coreml, bonsai, and benchmark.

The refactor timeline

The key refactor landed in June 2026 as the global move toward KMP and iOS release preparation. It split the Android app shell from shared modules, introduced the iOS app target, moved domain and presentation code toward common source sets, and replaced Android-only services with platform contracts.

From there, the work became incremental:

  • shared provider and configuration logic moved into common code;
  • Android-specific storage, permissions, wake locks, MediaStore, and local model execution stayed behind Android implementations;
  • iOS gained its own persistence, file handling, and platform bridges;
  • background generation was extracted behind common domain contracts;
  • local generation providers were added as capabilities, not as global assumptions;
  • the provider catalog became richer, with type, readiness, feature tags, version, and build availability.

The provider catalog is only the visible part. The deeper change is that local providers now have runtime-specific modules with their own model layout rules. The iOS Bonsai runtime, for example, does not assume a single archive shape. It can resolve an already extracted model, a wrapped Resources directory, or a model.zip archive, then validate that the MLX resources are actually present.

static func resolve(modelPath: String) throws -> BonsaiModelLayout {
    let modelURL = URL(fileURLWithPath: modelPath, isDirectory: true)
    if let layout = find(in: modelURL) {
        return layout
    }
 
    let archiveURL = modelURL.appendingPathComponent("model.zip", isDirectory: false)
    guard FileManager.default.fileExists(atPath: archiveURL.path) else {
        throw BonsaiRuntimeError.modelResourcesNotFound(modelPath)
    }
 
    let extractedURL = modelURL.appendingPathComponent("extracted", isDirectory: true)
    if find(in: extractedURL) == nil {
        if FileManager.default.fileExists(atPath: extractedURL.path) {
            try FileManager.default.removeItem(at: extractedURL)
        }
        try FileManager.default.createDirectory(
            at: extractedURL,
            withIntermediateDirectories: true
        )
        do {
            try FileManager.default.unzipItem(at: archiveURL, to: extractedURL)
        } catch {
            throw BonsaiRuntimeError.invalidModelArchive
        }
    }
 
    if let layout = find(in: extractedURL) {
        return layout
    }
 
    throw BonsaiRuntimeError.invalidModelLayout(
        "expected transformer-packed-mflux, text_encoder or text_encoder-mlx-4bit, tokenizer, vae, and scheduler directories"
    )
}
 
private static func find(in rootURL: URL) -> BonsaiModelLayout? {
    let candidates = [
        rootURL,
        rootURL.appendingPathComponent("Resources", isDirectory: true),
        rootURL.appendingPathComponent("extracted", isDirectory: true),
        rootURL
            .appendingPathComponent("extracted", isDirectory: true)
            .appendingPathComponent("Resources", isDirectory: true),
    ]
 
    if let direct = candidates.first(where: isBonsaiRoot) {
        return layout(rootURL: direct)
    }
 
    guard let enumerator = FileManager.default.enumerator(
        at: rootURL,
        includingPropertiesForKeys: [.isDirectoryKey],
        options: [.skipsHiddenFiles]
    ) else {
        return nil
    }
 
    let rootDepth = rootURL.pathComponents.count
    for case let url as URL in enumerator {
        if url.pathComponents.count - rootDepth > 4 {
            enumerator.skipDescendants()
            continue
        }
        if isBonsaiRoot(url), let layout = layout(rootURL: url) {
            return layout
        }
    }
    return nil
}

That is the kind of boundary that makes the refactor useful in practice. The shared app does not need to know how an MLX model archive is nested, where the tokenizer lives, or whether the model has already been extracted. The native runtime owns that complexity, while the shared product layer keeps a stable provider contract.

Challenge 1: one product, many provider shapes

The hardest part of SDAI is not drawing forms for prompts. It is keeping one product model across providers that do not behave the same way.

AUTOMATIC1111 handles batches one way. Fal.ai and ArliAI expose their own batch flows. Local ONNX, MediaPipe, stable-diffusion.cpp, Core ML, and PrismML Bonsai have device-specific constraints. Some providers support image-to-image. Some support inpainting. Some expose model lists, LoRA, embeddings, hypernetworks, or sampler discovery. Some do not.

The shared domain layer now hides those differences behind use cases and repositories, but the important part is not the when statement that chooses a provider. The important part is the contract around that choice: every provider has to fit the same product workflow of configuration, generation, progress, interruption, persistence, and result inspection.

That contract is what keeps the UI close to the user's task: choose a provider, enter a prompt, tune options, generate, review. The domain layer absorbs the fact that providers are not equivalent.

Challenge 2: one contract, different execution models

Background generation was one of the sharpest examples of KMP being an architectural tool, not just a code-sharing tool.

The shared product wants one thing: schedule generation, retry the last payload, cancel work, and observe status. Android and iOS need very different implementations to honor that promise.

The common contract stays intentionally small. It describes the product behavior, not the scheduler.

interface BackgroundTaskManager {
    fun scheduleTextToImageTask(payload: TextToImagePayload)
    fun scheduleImageToImageTask(payload: ImageToImagePayload)
    fun retryLastTextToImageTask(): Result<Unit>
    fun retryLastImageToImageTask(): Result<Unit>
    fun cancelAll(): Result<Unit>
}

On Android, the implementation can lean on WorkManager. SDAI serializes the payload into app-private work cache, cancels any previous unique generation work, and enqueues the next task with ExistingWorkPolicy.REPLACE.

override fun scheduleTextToImageTask(payload: TextToImagePayload) {
    runWork<TextToImageTask>(payload.toByteArray(), Constants.FILE_TEXT_TO_IMAGE)
}
private inline fun <reified W : ListenableWorker> runWork(bytes: ByteArray, fileName: String) {
    val workManager: WorkManagerProvider by inject(WorkManagerProvider::class.java)
    val workRequest = OneTimeWorkRequestBuilder<W>()
        .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
        .addTag(Constants.TAG_GENERATION)
        .build()
 
    writePayload(bytes, fileName)
    workManager().cancelUniqueWork(Constants.TAG_GENERATION)
    workManager().enqueueUniqueWork(
        Constants.TAG_GENERATION,
        ExistingWorkPolicy.REPLACE,
        workRequest,
    )
}

On iOS, the same contract becomes a coroutine-owned generation job. It has to start iOS background execution, subscribe to the active provider's progress stream, and close the background task in finally.

override fun scheduleTextToImageTask(payload: TextToImagePayload) {
    lastTextToImagePayload = payload
    runGenerationTask {
        textToImageUseCase(payload)
    }
}
private fun runGenerationTask(block: suspend () -> List<AiGenerationResult>) {
    activeJob?.cancel()
    clearStatusSubscriptions()
    backgroundWorkObserver.dismissResult()
 
    lateinit var generationJob: Job
    generationJob = coroutineScope.launch(start = CoroutineStart.LAZY) {
        val backgroundExecution = IosBackgroundExecution(
            name = "SDAI Generation",
            activeJob = { generationJob },
        )
        try {
            backgroundExecution.begin()
            listenSourceStatus()
            postRunningMessage()
            val result = block()
            if (isActiveJob(generationJob)) {
                backgroundWorkObserver.postSuccessSignal(result)
            }
        } catch (e: CancellationException) {
            if (isActiveJob(generationJob)) {
                backgroundWorkObserver.postCancelSignal()
            }
        } catch (t: Throwable) {
            if (isActiveJob(generationJob)) {
                backgroundWorkObserver.postFailedSignal(t)
            }
        } finally {
            if (isActiveJob(generationJob)) {
                clearStatusSubscriptions()
                activeJob = null
            }
            backgroundExecution.end()
        }
    }
    activeJob = generationJob
    generationJob.start()
}

Android can persist a serialized payload and hand it to WorkManager with a replace policy. iOS has to own a coroutine job, wrap it in background execution, subscribe to provider-specific status streams, and end the background task explicitly. The shared layer does not care which model is used. It cares that generation behaves like one product.

Challenge 3: local AI runtimes are not just another API call

Remote providers are mostly about HTTP, payload mapping, authentication, errors, and response normalization. Local generation is a different class of work.

On Android, SDAI already had local generation paths through Microsoft ONNX Runtime and Google AI MediaPipe. The newer work added local SDXL through stable-diffusion.cpp. On iOS, local generation needed a separate runtime story: Silicon Diffusion Core ML and PrismML Bonsai.

That means model manifests, downloads, storage locations, progress reporting, interruption, memory behavior, and user-facing availability all become part of the product. A local provider cannot be treated as "the same repository, but offline". It needs its own runtime module, its own file handling, and its own failure model.

The professional interest here is the boundary between shared product logic and native runtime code. The shared app should know that a local provider can generate, report progress, and be interrupted. It should not need to know whether the implementation is ONNX Runtime, MediaPipe, C++, Core ML, or MLX-backed Bonsai.

The Bonsai path is a good illustration. The shared Kotlin side sees a repository. Under it, there are three different layers: a Kotlin bridge that turns callback-based Swift into a cancellable suspend call, a Swift adapter that maps Kotlin/Native bridge objects to the native generator, and the MLX inference pipeline itself.

The Kotlin bridge owns product-level semantics: request mapping, progress emission, cancellation, and conversion of Swift completion callbacks into exceptions or a base64 image.

override suspend fun process(
    payload: TextToImagePayload,
    modelPath: String,
): String {
    statusFlow.tryEmit(LocalDiffusionStatus(0, payload.samplingSteps))
    return SiliconDiffusionBonsaiRuntimeRegistry.generate(
        request = payload.toBonsaiRequest(modelPath),
        onProgress = { progress ->
            statusFlow.tryEmit(
                LocalDiffusionStatus(
                    current = progress.current,
                    total = progress.total,
                ),
            )
        },
    )
}
internal suspend fun generate(
    request: SiliconDiffusionBonsaiRequest,
    onProgress: (SiliconDiffusionBonsaiProgress) -> Unit,
): String = kotlinx.coroutines.suspendCancellableCoroutine { continuation ->
    val bridge = runtime
    if (bridge == null) {
        continuation.resumeWithException(
            IllegalStateException("Bonsai Image runtime is not registered."),
        )
        return@suspendCancellableCoroutine
    }
 
    bridge.generate(
        request = request,
        onProgress = onProgress,
        completion = { response ->
            val image = response.imageBase64
            val error = response.errorMessage
            when {
                continuation.isActive && image != null -> continuation.resume(image)
                continuation.isActive -> continuation.resumeWithException(
                    IllegalStateException(error ?: "Bonsai Image generation failed."),
                )
            }
        },
    )
    continuation.invokeOnCancellation { bridge.interrupt() }
}

The Swift adapter is deliberately thin. Its job is to translate bridge DTOs into the native generator request and translate native progress and completion back into Kotlin-visible objects.

@available(iOS 17.0, *)
final class SiliconDiffusionBonsaiRuntimeAdapter: NSObject, SiliconDiffusionBonsaiRuntime {
    private let generator = SiliconDiffusionBonsaiGenerator()
 
    func generate(
        request: SiliconDiffusionBonsaiRequest,
        onProgress: @escaping @Sendable (SiliconDiffusionBonsaiProgress) -> Void,
        completion: @escaping @Sendable (SiliconDiffusionBonsaiResponse) -> Void
    ) {
        generator.generate(
            request: SiliconDiffusionBonsaiGenerator.Request(
                modelPath: request.modelPath,
                prompt: request.prompt,
                negativePrompt: request.negativePrompt,
                samplingSteps: request.samplingSteps,
                cfgScale: request.cfgScale,
                width: request.width,
                height: request.height,
                seed: request.seed,
                allowNsfw: request.allowNsfw
            ),
            onProgress: { progress in
                onProgress(
                    SiliconDiffusionBonsaiProgress(
                        current: progress.current,
                        total: progress.total
                    )
                )
            },
            completion: { response in
                let byteCount = response.imageBase64.flatMap { Data(base64Encoded: $0)?.count } ?? 0
                completion(
                    SiliconDiffusionBonsaiResponse(
                        imageBase64: response.imageBase64,
                        errorMessage: response.errorMessage
                    )
                )
            }
        )
    }
 
    func interrupt() {
        generator.interrupt()
    }
}

The inference layer is the part KMP should not try to abstract away. It is native code that deals with MLX memory pressure, scheduler steps, interruption, tensor shapes, and VAE decoding.

final class BonsaiMlxPipeline {
    private let layout: BonsaiModelLayout
 
    init(layout: BonsaiModelLayout) {
        self.layout = layout
    }
 
    func generate(
        input: BonsaiGenerationInput,
        onProgress: (SiliconDiffusionBonsaiGenerator.Progress) -> Void,
        shouldContinue: () -> Bool
    ) throws -> String {
        guard shouldContinue() else {
            throw BonsaiRuntimeError.interrupted
        }
 
        let encodedPrompts = try Self.encodePrompts(input: input, layout: layout)
        BonsaiMlxMemory.reclaimCache()
 
        var latents = BonsaiLatentCreator.preparePackedLatents(
            seed: input.seed,
            height: input.height,
            width: input.width
        )
        let scheduler = BonsaiFlowMatchEulerScheduler(
            imageSeqLen: (input.height / 16) * (input.width / 16),
            steps: input.steps
        )
        let total = Int32(scheduler.timesteps.count)
 
        try autoreleasepool {
            let transformer = try BonsaiFluxTransformer(layout: layout)
            BonsaiMlxMemory.reclaimCache()
 
            for index in scheduler.timesteps.indices {
                guard shouldContinue() else {
                    throw BonsaiRuntimeError.interrupted
                }
 
                let condNoise = transformer(
                    hiddenStates: latents.values,
                    encoderHiddenStates: encodedPrompts.prompt.embeddings,
                    timestep: scheduler.timesteps[index],
                    imageIds: latents.ids,
                    textIds: encodedPrompts.prompt.textIds
                )
                let noise: MLXArray
                if let negativePrompt = encodedPrompts.negativePrompt {
                    let uncondNoise = transformer(
                        hiddenStates: latents.values,
                        encoderHiddenStates: negativePrompt.embeddings,
                        timestep: scheduler.timesteps[index],
                        imageIds: latents.ids,
                        textIds: negativePrompt.textIds
                    )
                    noise = uncondNoise + MLXArray(input.guidance, dtype: condNoise.dtype) * (condNoise - uncondNoise)
                } else {
                    noise = condNoise
                }
                latents = BonsaiLatents(
                    values: scheduler.step(noise: noise, timestep: index, latents: latents.values),
                    ids: latents.ids,
                    latentHeight: latents.latentHeight,
                    latentWidth: latents.latentWidth
                )
                eval(latents.values)
                BonsaiMlxMemory.reclaimCache()
                onProgress(
                    SiliconDiffusionBonsaiGenerator.Progress(
                        current: Int32(index + 1),
                        total: total
                    )
                )
            }
        }
        BonsaiMlxMemory.reclaimCache()
 
        let packed = latents.values
            .reshaped(1, latents.latentHeight, latents.latentWidth, latents.values.shape[2])
            .transposed(0, 3, 1, 2)
        return try autoreleasepool {
            let vae = try BonsaiFluxVAE(layout: layout)
            BonsaiMlxMemory.reclaimCache()
            let decoded = vae.decodePacked(packed)
            eval(decoded)
            BonsaiMlxMemory.reclaimCache()
            return try BonsaiImageEncoder.base64Jpeg(from: decoded)
        }
    }
 
    private static func encodePrompts(
        input: BonsaiGenerationInput,
        layout: BonsaiModelLayout
    ) throws -> (prompt: BonsaiPromptEmbeddings, negativePrompt: BonsaiPromptEmbeddings?) {
        try autoreleasepool {
            let textEncoder = try BonsaiTextEncoder(layout: layout)
            let prompt = try textEncoder.encode(prompt: input.prompt)
            let negativePrompt: BonsaiPromptEmbeddings? = input.guidance > 1.0
                ? try textEncoder.encode(prompt: input.negativePrompt.isEmpty ? " " : input.negativePrompt)
                : nil
            if let negativePrompt {
                eval(prompt.embeddings, prompt.textIds, negativePrompt.embeddings, negativePrompt.textIds)
            } else {
                eval(prompt.embeddings, prompt.textIds)
            }
            return (prompt, negativePrompt)
        }
    }
}

That separation is what makes the work technically interesting. KMP does not pretend Swift MLX code is Kotlin. It makes the native runtime usable from the shared product layer without letting inference details leak into screens, use cases, or provider selection.

Challenge 4: shared UI without pretending platforms are identical

Compose Multiplatform made it possible to keep the mobile experience coherent across Android and iOS. But the goal was not pixel-level sameness. The goal was product continuity.

SDAI still has platform-specific behavior where users expect it: save flows, share sheets, file pickers, background execution, storage permissions, and local network behavior. The refactor moved those details behind contracts so the shared screens could stay focused on state and intent.

This is where the old clean architecture paid off most. ViewModels and use cases were already close to a platform-neutral shape. KMP made that discipline visible.

What changed for the product

After the refactor, SDAI is no longer "an Android app with plans for iOS". It is a cross-platform AI image generation client with a shared product core and platform-specific runtime depth.

Android keeps its established distribution and local generation paths. iOS gets a native release path, shared remote providers, and its own local generation work through Apple-oriented runtimes. The provider model has enough structure to keep growing without turning setup into a pile of special cases.

For users, the result is simple: more choice, fewer compromises, and the same product identity across devices.

For the codebase, the result is even more important: future providers can be added as capabilities instead of mini-rewrites.

Lessons

The main lesson is that KMP works best when it meets a codebase that already has boundaries. If the Android app had mixed UI, storage, HTTP, model execution, and platform APIs into one layer, the iOS release would have become a rewrite disguised as a refactor.

Instead, the existing architecture gave the project leverage. KMP turned that leverage into a shared product core. The native layers stayed native where they needed to. The shared layers became shared where they already made sense.

That is the kind of refactor worth doing: not because it is fashionable, but because it changes the cost of the next product decision.

SDAI is now positioned to evolve as a provider-independent mobile client for AI image generation, with Android and iOS moving together instead of drifting into separate products.

You can see the product showcase here: SDAI case study.