A small Swift package with the code shapes of a production app whose Swift code runs on iOS and Android, and whose Android UI is Kotlin. swift-java's jextract (JNI mode) generates the bindings, and Kotlin tests exercise them on the JVM and on an Android emulator.
The domain (a reading app) is made up; the shapes are the ones the app needs: how its modules depend on
each other, what it exports to Kotlin and what it keeps Swift-only, and how its view models talk to the UI.
It shows where swift-java can get to for a Kotlin app: the package uses upstream swift-java plus the series
of changes listed below, from the skip/integration branch of
skiptools/swift-java (the swift-java submodule).
With "kotlinOutput": true in each module's swift-java.config, Kotlin uses the Swift code like this
(adapted from Tests/shared):
val vm = ShelfViewModel.init(Dependencies.preview(), ShelfViewModel.Callbacks.init())
vm.load("cosmos") // a suspend call; cancelling the coroutine cancels the Swift task
when (val case = message.case) { // exhaustive over the enum's cases
is Message.Case.Closed -> case.reason // String?, not Optional<String>
is Message.Case.String -> case.arg0
is Message.Case.Event -> case.metadata // Map<String, String>?, a copy of the Swift dictionary
}
progress.updates().collect { show(it) } // a Flow over a Swift AsyncStream
book.shareText() // an extension function, from an extension in another module
LifecycleObserverFactory.init { id -> // Kotlin implements a Swift protocol, returned to Swift
object : LifecycleObserver { override fun dispose() = stop(id) }
}| Builds | Tests | |
|---|---|---|
| iOS | xcodebuild, without swift-java |
— |
| macOS | swift build, with or without jextract |
swift test (Swift), ./gradlew :jvm:test (Kotlin on the JVM) |
| Android | cross-compiled with the Swift SDK for Android | scripts/android-tests.sh (Kotlin on an emulator) |
Tests/ShapesTests exercises all of the Swift API, exported or not; the Kotlin tests exercise all of what
the bindings export.
Package.swift five Swift 6 modules, linked into one library (ShapesApp)
Sources/<Module>/ about 20 source files, and each module's swift-java.config
Tests/ShapesTests/ the Swift tests
Tests/shared/ the Kotlin tests, the same on the JVM and on Android
Tests/jvm, Tests/android how each platform runs code on the main thread
jvm/, android/ the Gradle projects that build the package and run the tests
swift-java/ skiptools/swift-java at skip/integration (submodule)
| Module | exportMode |
Shapes |
|---|---|---|
ShapesUtil |
all |
static helpers, constants inferred from literals and array literals, @autoclosure arguments, Java keywords as names, a method named like Object.wait(), platform-specific API |
ShapesModels |
annotated |
Codable entities with Foundation values, raw-value enums, enums with payloads (one carrying an Error, one whose case shadows a payload type and has an optional dictionary payload, one a closure), nested arrays, sets, optional labeled tuples, overloads that differ only in pointer types, clashing accessor names, a default-argument overload that would be ambiguous, a parameter named result, a @MainActor callback typealias |
ShapesClients |
annotated |
client protocols used as bare existentials, a protocol Kotlin implements, one taking dictionaries of Any, returned by a Kotlin closure, protocol extensions implementing requirements and adding methods, mocks that rely on them, a requirement taking a closure (Swift passes its closure to a Kotlin implementation), an extension of another module's type, dictionaries and sets of another module's types, Combine state, an AsyncStream, a long-running async function that stops when cancelled |
ShapesDependencies |
all |
a container of clients typed as another module's protocols, @autoclosure @escaping configuration returning another module's enum, app state, async functions taking entities |
ShapesViewModels |
annotated |
@MainActor @Observable view models, callbacks typed with another module's @MainActor typealias, callbacks taking entities, arrays of entities, [URL], optional strings and numbers, async callbacks (suspend lambdas in Kotlin), a closure returning an optional array of entities, a callback handed a Swift closure to call later |
Every module sets failOnSkippedDeclarations, so building with jextract fails when it can't handle
something. Shapes it doesn't handle yet are marked @JavaExport(false) with a // Gap: comment
(Gaps and limits).
git clone --recurse-submodules https://github.com/skiptools/swift-java-app-shapes.git
swift test # Swift tests (SWIFT_JAVA_BUILD=1 swift test: with jextract)
./gradlew :jvm:test # macOS: Kotlin tests on the JVM (builds the package with jextract)
xcodebuild -scheme swift-java-app-shapes -destination 'generic/platform=iOS Simulator' build
scripts/android-tests.sh # Kotlin tests on an Android emulatorApple platforms build without swift-java: as in
swiftlang/swift-java#694, the package adds swift-java
and the jextract plugin only when SWIFT_JAVA_BUILD is set (the Gradle builds set it), and otherwise
depends only on the JNI-free SwiftJavaAnnotations for the @JavaExport markers.
Android needs the Android SDK with an arm64 emulator image, the Swift SDK for Android and the
swift.org toolchain of the same version (6.4.2 by default; -PswiftToolchain=<path> and
-PswiftAndroidSdk=<path> change them). Both are on swift.org/install;
the SDK installs with swift sdk install <artifact bundle URL>.
The Android project cross-compiles the package, packages libShapesApp.so with the Swift runtime, and
runs Tests/shared as instrumented tests. Its test runner loads the Swift libraries on the main thread
before the tests run, as an app does at startup: libdispatch binds its main queue, which Swift's main
actor runs on, to the thread that loads it.
"kotlinOutput": true (opt-in; without it the generated Java is unchanged) keeps the generated Java as the
binding layer (same JNI glue, SwiftKit runtime and Gradle setup) and
- emits Java that reads well from Kotlin: nullable types instead of
Optional, dictionaries asMapcopies andAnyas Kotlin'sAny(as Skip bridges them), andasyncand stream methods named<name>Asyncand<name>Streamso the Kotlin functions below aren't shadowed by them; - adds a
<Module>Kotlin.ktper module, which needs kotlin-stdlib and kotlinx-coroutines (hence opt-in):suspendfunctions,Flows, extension functions, andsuspending { }for async callbacks.
The Gradle projects compile the generated .kt with the Java (kotlin.srcDirs) and depend on
kotlinx-coroutines-core. Before is what Tests/shared wrote without kotlinOutput; after is what it writes
now.
Optionals in closure parameters and enum payloads (method parameters and results were already
nullable, with optionalsAsNullable, which kotlinOutput implies):
// Before
{ code: Optional<String>, attempt: OptionalLong ->
verified += code.orElse(null) to (if (attempt.isPresent) attempt.asLong else null)
}
assertEquals("bye", closed.reason.get())
// After
{ code: String?, attempt: Long? -> verified += code to attempt }
assertEquals("bye", closed.reason)Enums with payloads: their Case is a Java sealed interface of records, which Kotlin's when checks
exhaustively (a missing case doesn't compile: ModelsTest.enumPayloads):
// Before
val closed = Message.closed(1000, "bye").getAsClosed()!!.get()
assertEquals(1000, closed.code)
// After
when (val case = message.case) {
is Message.Case.Closed -> assertEquals(1000, case.code)
is Message.Case.String, is Message.Case.Event -> fail()
}async functions, as suspend functions that throw the Swift error; cancelling the coroutine cancels
the Swift task (ClientsTest.cancellingTheCoroutineCancelsTheSwiftTask):
// Before
val offline = assertFailsWith<ExecutionException> { state.borrow(book).get() }
assertIs<LibraryError>(offline.cause)
vm.load("a").awaitOnMain() // a helper running the main loop while it waits
// After (import com.example.shapes.dependencies.borrow)
assertFailsWith<LibraryError> { state.borrow(book) }
vm.load("a") // in a coroutine on the main threadasync callbacks, as suspend lambdas run by suspending { }:
// Before
{ vm: ShelfViewModel, genre: Genre -> shared += vm.appName; CompletableFuture.completedFuture(null) }
// After
{ vm: ShelfViewModel, genre: Genre -> suspending { shared += vm.appName } }Dictionaries and Any, as Map copies and Kotlin's Any (dictionariesAsJavaMaps, which kotlinOutput
implies), in both directions, as Skip bridges them: numbers, strings and booleans are boxed, arrays are Lists,
dictionaries Maps, and nil is null (ClientsTest.dictionariesAndAnyAreKotlinMaps):
// Before: a SwiftDictionaryMap, which Kotlin reads but can't create; Any was skipped
// After
val client = object : AnalyticsClient { // track(event:properties: [String: Any]) in Swift
override fun track(event: String, properties: Map<String, Any>) { … }
override fun handlePush(userInfo: Map<Any, Any>): Boolean = …
}
AnalyticsRecorder.init(client).record(AnalyticsInput.init("opened", mapOf("count" to 2L, "tags" to listOf("a"))))
Message.event("page", mapOf("k" to "v")) // case event(name: String, metadata: [String: String]?)AsyncStreams, as Flows; cancelling the collection cancels the Swift task iterating the stream:
// Before: no shape
// After
val values = progress.updates().toList() // DownloadProgress.updates() -> AsyncStream<Double>An extension of another module's type, as extension functions:
// Before
val sharing = com.example.shapes.clients.Book.wrapMemoryAddressUnsafe(book.`$memoryAddress`(), SwiftArena.ofConfined())
sharing.shareText()
// After (import com.example.shapes.clients.shareText)
book.shareText()skip/integration is upstream main plus the branches below, merged in this order. Each is one change, with
its tests, on piercifani/swift-java, and its link shows its diff
against upstream. Five are open upstream PRs; the others aren't opened yet. Each was checked against this
package, and against the app it comes from (no new skipped declarations, javac or Swift errors).
| # | Change | Branch | Based on | Upstream PR |
|---|---|---|---|---|
| 1 | Escaping closures taking or returning jextracted types, @Sendable/@MainActor closures, across modules |
jextract-closures-jextracted-types |
upstream main |
|
| 2 | Report every skipped declaration; failOnSkippedDeclarations |
jextract-skipped-declarations |
upstream main |
|
| 3 | Enum payload types shadowed by the Case records' names |
jextract-enum-case-name-collision |
upstream main |
|
| 4 | Int closures, inout thunks; the types of unannotated properties (literals, array literals, initializers, Type.member, enum cases with payloads, a property of a new value) |
jextract-codegen-fixes |
branch 2 | |
| 5 | Java implementations of async and cross-module protocols |
jextract-java-implemented-protocols |
upstream main |
|
| 6 | Swift class hierarchies as Java class hierarchies | jextract-class-inheritance |
upstream main |
|
| 7 | Cancelling an async function's future cancels the Swift task |
jextract-async-cancellation |
branch 5 | |
| 8 | Wrappers of the same Swift object are equal; getCase() without an arena |
jextract-object-identity |
upstream main |
|
| 9 | AsyncStream results as SwiftAsyncStream |
jextract-async-stream |
branch 2 | |
| 10 | AsyncThrowingStream results, errors delivered to onError |
jextract-async-throwing-stream |
branch 9 | |
| 11 | Arrays of optionals, dictionaries and sets | jextract-optional-nested-arrays |
branch 2 | |
| 12 | payloadlessEnumsAsJavaEnums: Java enums for enums without payloads |
jextract-java-enums |
upstream main |
|
| 13 | Java overloads for trailing default arguments | jextract-default-arguments |
upstream main |
|
| 14 | optionalsAsNullable: optionals as nullable Java types |
jextract-nullable-optionals |
upstream main |
|
| 15 | Non-escaping closures taking or returning jextracted types | jextract-closure-struct-arguments |
branch 1 | |
| 16 | Java enum constants keep the Swift case names | jextract-enum-swift-case-names |
branch 12 | |
| 17 | nullableAnnotation and nonNullAnnotation, for Kotlin's nullability (arrays stay platform types) |
jextract-nullability-annotations |
branch 14 | |
| 18 | @JavaExport(false) and exportMode; prefix and postfix operators skipped |
jextract-export-control |
upstream main |
#950, #951 |
| 19 | foundationTypesAsJavaTypes: Foundation values as standard Java types |
jextract-foundation-java-types |
upstream main |
#952 |
| 20 | closureMethodName: the method name of closure interfaces |
jextract-closure-method-name |
upstream main |
#953 |
| 21 | Swift names that are Java keywords | jextract-java-identifiers |
upstream main |
#954 |
| 22 | Overloads that lower to the same native signature | jextract-name-collisions |
upstream main |
|
| 23 | A bare protocol in a value position is an existential | jextract-implicit-existentials |
upstream main |
|
| 24 | Arrays of arrays of jextracted types | jextract-nested-object-arrays |
upstream main |
|
| 25 | Interfaces of unlabeled closure parameters | jextract-unnamed-closure-parameters |
upstream main |
|
| 26 | Protocol extension members as default methods of the Java interface | jextract-protocol-extensions |
upstream main |
|
| 27 | Conformers take the protocol's functional interfaces for closure parameters | jextract-protocol-closure-parameters |
branch 26 | |
| 28 | Optional arrays of primitives as results | jextract-optional-result-types |
upstream main |
|
| 29 | Type bridges usable by other modules' thunks | jextract-public-type-bridges |
upstream main |
|
| 30 | async thunks capture jextracted arguments |
jextract-async-argument-captures |
upstream main |
|
| 31 | @autoclosure parameters take a Java lambda |
jextract-autoclosure-parameters |
upstream main |
|
| 32 | Parameters named like the thunk's own locals | jextract-reserved-parameter-names |
upstream main |
|
| 33 | Thunks for another module's type go in the extending file | jextract-extension-thunk-files |
upstream main |
|
| 34 | Enum cases with closure payloads | jextract-enum-unbridged-payloads |
upstream main |
|
| 35 | Closures returning arrays of jextracted types, optional or not | jextract-closure-array-results |
branch 1 | |
| 36 | javaCallbacksBindings: jextract; closures returning a protocol Kotlin implements; bindings for async requirements' types |
jextract-protocol-bindings |
branch 35 | |
| 37 | kotlinOutput: Java shaped for Kotlin, and a Kotlin file per module |
jextract-kotlin-output |
skip/integration |
|
| 38 | A Sendable protocol's existential captured without nonisolated(unsafe) |
jextract-sendable-existential-captures |
upstream main |
|
| 39 | swift-java's Gradle scripts without APIs Gradle deprecates | gradle-deprecations |
upstream main |
|
| 40 | Optional arrays as parameters, setters and initializer arguments included | jextract-optional-array-parameters |
upstream main |
|
| 41 | Self results of structs, enums and classes |
jextract-self-results |
upstream main |
|
| 42 | Closure types keep their parameters' names; a closure implemented in Java can take a Swift closure | jextract-closure-closure-parameters |
skip/integration |
|
| 43 | dictionariesAsJavaMaps: dictionaries as java.util.Map copies and Any as Object, as Skip bridges them |
jextract-dictionaries-as-maps |
skip/integration |
|
| 44 | Swift passes a Swift closure to a Kotlin implementation of a requirement taking one | jextract-closure-requirements |
skip/integration |
skip/integration also has, beyond these branches (see Gaps and limits):
- the change of #938 (
@MainActorisolation in synchronous thunks), closed upstream; - a few commits that combine several branches (
Integration: …), not yet split into branches of their own, among them what makes every protocol of a module with Java callbacks implementable in Kotlin.
These are what this repository had to set up itself, and what an app would too:
SwiftKitCore, the Java runtime of the bindings, isn't published: the Gradle build includes theswift-javasubmodule's build (includeBuild) to get it.- The Gradle helpers swift-java's own samples use (running jextract, finding the generated Java, library
paths) are internal to its repository:
jvm/build.gradle.ktsandandroid/build.gradle.ktsdo the same in a few lines each. - One library for all modules (
nativeLibraryNamein eachswift-java.config): with a library per module, each links its own copy of the modules it depends on, and Swift casts fail between the copies. - The Swift SDK for Android needs the swift.org toolchain of its version, first on
PATH: swift-java's build plugins runswiftthemselves. - Kotlin implementations of Swift protocols (
ShapesClients) needenableJavaCallbacks. By default that runs Gradle, javac and wrap-java inside the SwiftPM build, with its sandbox disabled;"javaCallbacksBindings": "jextract"has jextract print the Swift bindings it needs instead.
What jextract doesn't handle yet, marked @JavaExport(false) with a // Gap: comment: fixing one means
removing its annotation, which the build and the Kotlin tests then check.
#if: without a static build configuration jextract treats every platform as active, so a macOS build gets thunks for#if os(Android)API (Platform.androidOnly(),ShelfViewModel.androidBackPressed()), and it treatscanImport(_:)as false.- Sets as parameters (
Shelf.init(id:books:tags:)): Kotlin can't create aSwiftSet, so it can pass only a set Swift returned (the test marks it with// Gap:).
kotlinOutput, as prototyped:
- Enums keep their class and a sealed
getCase()(when (val case = message.case)), rather than becoming the sealed type themselves (when (message)), which would make each case a subclass holding the Swift value. asynccallbacks still return a future in Java, so Kotlin passessuspending { … }rather than a bare suspend lambda: a Java functional interface can't take one.- Closure results are still Java optionals (
ShelfViewModel.setSeedProvider), and a type's staticasyncfunctions get no Kotlin wrapper (Kotlin can't add static members to a Java class). @MainActorwork: asuspendcall on Android's main dispatcher lets the main looper run it; the JVM tests'runMainis a main dispatcher that runs Swift's main loop.- Default-argument overloads that would be ambiguous with the
SwiftArenaones are left out, so which arities a function with trailing defaulted closures gets changes when it gains one (ShelfViewModel.Callbacks.init). - Out of scope: Observation to Compose state (Skip's), and arrays as
Lists.
The path upstream: a few changes aren't yet on a branch based on upstream main. jextract-kotlin-output,
jextract-closure-closure-parameters, jextract-dictionaries-as-maps and jextract-closure-requirements are based on skip/integration, the Integration: … commits combine several branches, and #938's change is
closed upstream. Splitting them into branches like the others makes skip/integration exactly upstream plus
the list above.