Skip to main content
In this tutorial you put the splash feature from Tutorial 1 on two screens. First you build the Kotlin core into an Apple framework and replay the four recordings across the Swift boundary, so the reducer that reaches the iOS app is provably the one the Kotlin lane checked. Then you write the two shells: a Swift shell and a SwiftUI view in the iOS app, and a Compose screen in the Android app. Each shell does three things and nothing else: it turns intents into actions, projects state into what the view renders, and brackets the store’s lifetime. At the end both apps play the splash from the same store and land on the same placeholder screen; Tutorial 3 replaces that placeholder with the feature tree. This is the second page of the nine-tutorial series.
The manual setup below is what Modaal automates. Every tree this tutorial opens and every file it asks you to write is produced by the Duet templates in the Modaal new-project wizard, and a coding agent working in Modaal writes the feature, records it and runs the checks for you. This series walks the same ground by hand so you know what the scaffold emits and why: start a Duet project in the wizard when you would rather skip the setup.

What will you build?

The Kotlin module you wrote in Tutorial 1 becomes one static framework, FoyerKit, built by Gradle and linked by two Swift packages: a test-only package that replays the recordings across the boundary, and the app’s consumer package that holds the bridge and the shell. The iOS app is a scene delegate, a host object and two SwiftUI views; the Android app is an Activity, the same host in Kotlin and two composables. Neither app contains a line of feature logic. The splash reveals the app’s name over 1.6 seconds, sends CeremonyFinished when the reveal ends, and the host swaps in a placeholder that names which path completed it. This is the most setup-heavy tutorial in the series. The framework assembly is the setup the Modaal wizard automates; here you write it by hand, in three files and one script. Expect about two hours, most of it in the first four steps. You will have at the end:
  • src-kmp/apple-umbrella: the framework module and the replay boundary it exports, assembled by scripts/assemble_kit.sh.
  • Six passing tests in src-kmp/apple-umbrella/swift-consumer: the four recordings replayed across the boundary, plus two on the error channel.
  • src-ios/Libraries/FoyerKit: the FoyerBridge target with the store mirror, the SplashShell target with the shell, the builder and the view, and three passing shell tests.
  • src-ios/App: the iOS app, built from an XcodeGen spec.
  • src-kmp/app: the Android app, with two passing host tests on the JVM.
  • A green parity/scripts/apple-boundary-lane.sh, and tools/duet doctor reporting two target declarations.

Where do you start?

Open tutorial2-start from the duet-tutorials repository. It is Tutorial 1’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0 and duet-services 0.11.1. You need Xcode 26.6, a JDK 25, XcodeGen (brew install xcodegen) and the Android SDK with platform 36; the series index lists them. Run the checks once before you edit anything:
The Kotlin lane reports 10 tests with one failure, Tutorial2ExerciseMountBracketTest; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS.

The steps

1

Declare the Apple framework module

The Apple side of a Duet app links one Kotlin/Native framework that aggregates the kernel and every feature module. One framework rather than one per feature, because a static Kotlin/Native framework embeds the Kotlin runtime and two of them would carry it twice. The module is src-kmp/apple-umbrella. It applies the multiplatform plugin and SKIE, the compiler plugin that projects sealed hierarchies as Swift enums and StateFlow as an async sequence. Add the SKIE version and plugin to the version catalog:
src-kmp/gradle/libs.versions.toml
src-kmp/gradle/libs.versions.toml
Register it once in the root build file with apply false, and include the module in the settings file:
src-kmp/build.gradle.kts
src-kmp/settings.gradle.kts
The module’s build file declares three arm64 targets, names the framework, and exports the feature module and the kernel. export is what puts a dependency’s declarations in the framework’s headers; a module that is linked but not exported compiles, and Swift cannot name a single type from it.
src-kmp/apple-umbrella/build.gradle.kts
Check it with (cd src-kmp && ./gradlew projects -q); the hierarchy now lists Project ':apple-umbrella'.
2

Export the replay boundary

A framework over an empty commonMain produces no XCFramework at all, so the module needs a source file, and there is one thing that is per app rather than per feature: the replay registry the Swift side drives. It names the same four declarations the Kotlin replay runner names, one entry per feature, and exposes two calls: canonicalize any fixture JSON through the core’s own writer, and open a replay session over a registered feature.
src-kmp/apple-umbrella/src/commonMain/kotlin/dev/modaal/foyer/kit/FoyerBoundary.kt
The @Throws annotations are a contract, not a style: a Kotlin exception that crosses Kotlin/Native without one terminates the process. Build the framework once with (cd src-kmp && ./gradlew :apple-umbrella:assembleFoyerKitDebugXCFramework). The first build downloads the Kotlin/Native toolchain and compiles three targets; allow ten minutes. It ends with BUILD SUCCESSFUL, and src-kmp/apple-umbrella/build/XCFrameworks/debug/FoyerKit.xcframework exists.
3

Assemble the framework with one script

SwiftPM links a prebuilt binary and has no build-graph link to Gradle. Every Swift consumer therefore points at one path that a single script writes, so swift test and the app build always link what was assembled last, and nothing links Gradle’s per-flavor output directories directly.
scripts/assemble_kit.sh
The full script also resolves JAVA_HOME when it is unset, because Xcode runs script phases with a minimal environment. Run scripts/assemble_kit.sh debug; its last line is assemble_kit: debug → src-kmp/apple-umbrella/build/XCFrameworks/app/FoyerKit.xcframework.
4

Replay the recordings across the boundary

The first consumer is a test-only Swift package next to the module. Its manifest declares the binary target at the consumed path and one test target that links it. Kotlin/Native frameworks need libc++, which Swift does not autolink.
src-kmp/apple-umbrella/swift-consumer/Package.swift
The harness reads a recording from parity/fixtures/, opens a session, and sends each step’s action through it. The expected state and effects go through the core’s canonicalize before the comparison, so both sides of every assertion come from the same writer.
src-kmp/apple-umbrella/swift-consumer/Tests/BoundaryTests/BoundaryReplayHarness.swift
One test method per recording, so a recording without a row here is a recording this suite does not check:
src-kmp/apple-umbrella/swift-consumer/Tests/BoundaryTests/SplashBoundaryReplayTests.swift
A second file, BoundaryErrorChannelTests.swift, pins that an unregistered feature name arrives in Swift as a catchable error carrying the Kotlin message, and that canonicalize orders keys. Run (cd src-kmp/apple-umbrella/swift-consumer && swift test); the summary line reads Executed 6 tests, with 0 failures.
5

Write the consumer package and the store mirror

The app links the framework through a second package, src-ios/Libraries/FoyerKit. It has a target for the bridge, one shell target per feature, and one test target per shell, and it pins the Duet Swift package exactly at 0.7.0, the same release the Kotlin side resolves.
src-ios/Libraries/FoyerKit/Package.swift
The bridge target holds one class. The Kotlin Store is the runtime; BridgedStore is its Swift face: a @Published mirror of the bridged StateFlow, a synchronous send, and a cancel() that stops the Kotlin runtime. The class exists for one reason. A shell must observe a reduce before send returns, and a bridged StateFlow delivers asynchronously, so the mirror re-reads the Kotlin state in the same call.
src-ios/Libraries/FoyerKit/Sources/FoyerBridge/BridgedStore.swift
The class is feature-generic; every shell in the app uses it with its own state and action types.
6

Write the Swift shell

The shell subclasses ViewShell from the Duet shells package. Its bind() runs at activate() and adopts two things into the shell’s StoreHost: the store mirror, and a StateTransitions observation that projects each state into the view state. Adopting the mirror first means it unwinds last, after the projection, when deactivate() runs. The two intents are the two things the view reports.
src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashViewShell.swift
The environment interface from Tutorial 1 arrives in Swift as an Objective-C protocol, so an NSObject subclass implements it: the clock is the Kotlin LiveClock object, and notifyHost forwards to a closure the host supplies. The builder wires a mount in four lines: a main-immediate scope, the Kotlin store from makeSplashStore, the mirror over splashStateFlow, and the shell.
src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashBuilder.swift
src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashBuilder.swift
The teardown closure is what makes deactivate() reach the Kotlin side: it cancels the store’s effects and then the scope they ran in.
7

Write the SwiftUI view

The view reads the shell’s view state and calls the shell’s intents. It owns the reveal animation, because the animation is presentation: the feature knows nothing about 1.6 seconds, only that a ceremony ends. onAppear reports appeared(), and the animation’s completion reports ceremonyFinished(). The progress line appears when the state says the safety net is armed.
src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashView.swift
Build the package with (cd src-ios/Libraries/FoyerKit && swift build); it ends with Build complete!.
8

Pin the crossing in the shells lane

The shell’s tests check what only the bridge can break, and nothing the recordings already pin. The first row drives the builder’s wiring and asserts that appeared() projects back before it returns. The second builds the runtime by hand with a spy environment, so the test can see the Kotlin effect loop call back into Swift with the ceremony path. The third deactivates the shell and shows that a further action still reduces but runs no effect, because the scope is gone.
src-ios/Libraries/FoyerKit/Tests/SplashShellTests/SplashViewShellSpec.swift
src-ios/Libraries/FoyerKit/Tests/SplashShellTests/SplashViewShellSpec.swift
These rows run on real time, deliberately: no test dispatcher crosses the boundary, and virtual time stays with the Kotlin lane. One script, the Apple boundary lane, runs the assembly and both packages in the order SwiftPM cannot express, and fails if either package executed zero tests. Its second half, the consumer package’s own tests, is what this series calls the shells lane:
parity/scripts/apple-boundary-lane.sh
Run parity/scripts/apple-boundary-lane.sh; it prints boundary replay: 6 test(s) executed, shells: 3 test(s) executed and ends with apple-boundary-lane: PASS.
9

Build the iOS app

The app target has no feature code. A host object mounts the splash through the builder, receives its delegate event, tears the splash down and switches the phase; a view switches on the phase. Both notification paths call the host, and the host acts on the first only.
src-ios/App/Foyer/AppHost.swift
onEnum(of:) is SKIE’s projection of the Kotlin sealed interface as a Swift enum. The screen renders the splash while the phase is .splash and the placeholder after:
src-ios/App/Foyer/AppScreen.swift
The scene delegate builds the host, shows the window, and activates the host after the window is visible, so the first frame renders from mounted state. sceneDidDisconnect calls teardown(), because dropping the reference alone cancels nothing on the Kotlin side. The project itself is generated from an XcodeGen spec that names the consumer package’s two products and runs the assemble script as the scheme’s build pre-action:
src-ios/App/xcodegen.yml
src-ios/App/xcodegen.yml
Generate and build with (cd src-ios/App && xcodegen generate --spec xcodegen.yml && xcodebuild build -project Foyer.xcodeproj -scheme Foyer -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 | tail -1); the last line is ** BUILD SUCCEEDED **. ARCHS=arm64 matters: the framework has no x86_64 slice, and a generic simulator build otherwise compiles for both.
10

Build the Compose app

The Android app is a Gradle module, src-kmp/app, on the same plane as the feature modules. It depends on the splash module directly, on the kernel, and on the Duet shells-compose artifact for StoreHost and RetainedRoot.
src-kmp/app/build.gradle.kts
The builder is the Swift builder’s twin, shorter because there is no boundary to cross: the live environment is a Kotlin class, and the store is the module’s own makeSplashStore.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/SplashBuilder.kt
The host is the same object as on iOS, in Kotlin, and it is Android-free so a JVM test can drive it. It registers the store in a StoreHost and tears it down on the first completion:
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/AppHost.kt
On Android the composable is the shell: it collects the store’s state, and its launched effect sends Appeared, plays the reveal, then sends CeremonyFinished. After a rotation the effect runs again; the reducer’s arming guard makes the second Appeared inert, and the host ignores a second completion. The rules you recorded in Tutorial 1 are what make rotation safe here.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/SplashScreen.kt
The Activity keeps the host on a retained scope, so rotation recreates the Activity and getOrCreate hands back the same host with its running store; finishing the Activity is the one teardown.
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainActivity.kt
AppRoot collects the phase and shows SplashScreen or the placeholder. Include the module in the settings file (include(":app")), then build with (cd src-kmp && ./gradlew :app:assembleDebug); it ends with BUILD SUCCESSFUL.
11

Test the host on the JVM

Because the host is Android-free, its test runs on the JVM with the store on the test scope, so the safety net waits on virtual time. The first test drives the ceremony path and then checks that the torn-down store’s net never fires; the second advances the clock past the duration instead.
src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/AppHostTest.kt
Run (cd src-kmp && ./gradlew :app:testDebugUnitTest); it ends with BUILD SUCCESSFUL and the report under src-kmp/app/build/reports/tests/testDebugUnitTest/ lists two tests.
12

Declare the targets and run both apps

tools/duet doctor reads the tree’s declaration file and cross-checks it against what is on disk. Declare the two app targets, paired under one name:
.modaal/project.json
Run tools/duet doctor; it ends with duet doctor: PASS — 2 target declaration(s), 10 Swift source file(s) scanned. Then run the apps. On iOS, open src-ios/App/Foyer.xcodeproj and run the Foyer scheme on an iPhone simulator; the pre-action assembles the core first. On Android, start an emulator and run (cd src-kmp && ./gradlew :app:installDebug), then open Foyer from the launcher. Both apps reveal the name over 1.6 seconds and then show the placeholder with the line Splash completed by the ceremony.
Two phone screens side by side, an iPhone simulator on the left and an Android emulator on the right. Each shows the word Foyer in large type, the line One core, two apps under it, and a thin progress line below, all partly faded in during the reveal animation.

The splash mid-reveal on both platforms: an iPhone 17 simulator on the left and a Pixel 8 emulator at API 36 on the right, from tutorial2-complete at Duet 0.7.0 and duet-tools 0.24.0.

To see the other path, set a breakpoint in the reveal’s completion on either platform and wait three seconds; the placeholder then reads Splash completed by the safety net.

What you now have

  • One framework, FoyerKit, assembled from the Kotlin core by scripts/assemble_kit.sh and linked by two Swift packages.
  • The four recordings replayed across the Swift boundary, plus two error-channel tests, in src-kmp/apple-umbrella/swift-consumer.
  • A Swift shell with its builder, view and three tests in src-ios/Libraries/FoyerKit, and a Compose screen with its builder, host and two tests in src-kmp/app.
  • Two apps that play the splash from the same store and land on the same placeholder.
  • A green parity/scripts/apple-boundary-lane.sh, a green tools/duet verify, and a doctor report with two targets.
The feature module did not change by one line between Tutorial 1 and here, and that is the point of the series.

Exercise: finish the mount-bracket test

tutorial2-start carries Tutorial2ExerciseMountBracketTest in the splash module’s jvmTest. Its given is written: one test store, armed, then torn down. Finish it so it pins that a second store over the same environment arms a fresh net, and that after advancing the clock past the duration the environment records exactly one Completed(SafetyNet), the second store’s. The first store’s net was cancelled at teardown, and first.finish() fails on any action that arrived there. Run tools/duet verify; the Kotlin lane reports 10 tests with no failure and the run ends with duet verify: PASS. tutorial2-complete carries the finished test as SplashMountBracketTest.

Common questions

Because a shell must observe a reduce before send returns, and every delivery of a bridged StateFlow crosses a continuation hop. BridgedStore.send re-reads the Kotlin state synchronously after forwarding the action, which is the ordering StateTransitions and the other shell helpers assume. Effect-fed actions, such as the safety net’s tick, still arrive through the collector one hop later.
A static Kotlin/Native framework embeds the Kotlin runtime. Two frameworks in one app carry it twice and can disagree about shared types at the boundary. The module boundaries between features stay enforced by Gradle; the Apple side sees one binary.
StoreHost tears down in reverse registration order. Adopting the store first means the projection stops before the store’s cancel() ends its effects, so no observation fires into a shell that is half torn down. On Android the same registry is StoreHost in AppHost, and RetainedRoot calls its teardownAll on logical destruction.
The reveal is presentation; each platform owns its animation and may change it without touching a recording. The safety net is behavior: it decides when the host is told, so it lives in the reducer, and its duration is in every recording. The Swift spec and the Kotlin test-clock suite both pin that the net still fires when no CeremonyFinished arrives.
No test dispatcher crosses the boundary, so the Swift side cannot advance the Kotlin store’s clock. The rows that need to wait poll in 20 ms slices against a ceiling, and the teardown row waits 200 ms for an effect that must not arrive. Timing behavior is pinned on the Kotlin lane, where virtual time is available.
In the AppHost that RetainedRoot keeps in the Activity’s InstanceKeeper. Rotation recreates the Activity and the composition, and getOrCreate hands the same host back with its store still running; the composable’s effect sends Appeared again, which the arming guard makes inert. Tutorial 5 adds process-death restore, which this mechanism does not cover.

Sources and further reading

Tutorial 1: Your First Feature

The splash feature both apps on this page mount: state, actions, effects and the recordings.

Tutorial 3: Composing Features

The root, the sign-in gate and the profile tree: children mounted from state, delegate events received as parent actions, and chain recordings pinning each seam.

The Duet tutorial series

The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
Last modified on September 8, 2026