Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift.org project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

import KotlinSampleModels

/// Uses a protocol declared in KotlinSampleModels, implemented in Kotlin.
public final class ProfileService: Sendable {
let client: any ProfileClient

public init(client: any ProfileClient) {
self.client = client
}

public func title(id: String) -> String {
client.displayName(for: id)
}

public func name(id: String) -> String {
client.profile(id: id).name
}

public func load(id: String) async throws -> String {
try await client.fetch(id: id).name
}

/// Starts a fetch, cancels it, and reports whether it ended without a value.
public func fetchThenCancel(id: String) async -> Bool {
let client = self.client
let task = Task { try await client.fetch(id: id) }
try? await Task.sleep(for: .milliseconds(50))
task.cancel()
return (try? await task.value) == nil
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift.org project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

/// A protocol implemented in Kotlin and passed into Swift code in another module.
public struct Profile: Sendable {
public var name: String

public init(name: String) {
self.name = name
}
}

public enum ClientError: Error {
case offline
}

public protocol ProfileClient: Sendable {
func displayName(for id: String) -> String
func profile(id: String) -> Profile
/// Implemented in Kotlin by returning a `CompletableFuture`, which Swift awaits without blocking.
func fetch(id: String) async throws -> Profile
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
"javaPackage": "com.example.kotlin.models",
"mode": "jni",
"memoryManagementMode": "allowGlobalAutomatic",
"failOnSkippedDeclarations": true
"failOnSkippedDeclarations": true,
"enableJavaCallbacks": true
}
11 changes: 11 additions & 0 deletions Samples/SwiftJavaExtractJNIKotlinSampleApp/ci-validate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ else
DISABLE_EXPERIMENTAL_PREBUILTS='--disable-experimental-prebuilts'
fi

if [[ "$(uname)" == "Darwin" && -n "$GITHUB_ACTION" ]]; then
# WORKAROUND (as in SwiftJavaExtractJNISampleApp): with enableJavaCallbacks the jextract plugin runs
# Gradle in its own GRADLE_USER_HOME, which times out downloading the wrapper on GitHub's macOS runners.
./gradlew -h
GRADLE_USER_HOME="$(pwd)/.build/plugins/outputs/swiftjavaextractjnikotlinsampleapp/KotlinSampleModels/destination/JExtractSwiftPlugin/gradle-user-home"
if [ -d "$HOME/.gradle" ] ; then
mkdir -p "$GRADLE_USER_HOME"
cp -r "$HOME/.gradle/"* "$GRADLE_USER_HOME/" || true
fi
fi

# FIXME: disable prebuilts until prebuilt swift-syntax isn't broken on 6.2 anymore: https://github.com/swiftlang/swift-java/issues/418
swift build $DISABLE_EXPERIMENTAL_PREBUILTS --disable-sandbox

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift.org project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

package com.example.kotlin

import com.example.kotlin.models.Profile
import com.example.kotlin.models.ProfileClient
import kotlinx.coroutines.future.await
import kotlinx.coroutines.runBlocking
import org.swift.swiftkit.core.SwiftArena
import java.util.concurrent.CompletableFuture
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue

/// A Kotlin implementation of a Swift protocol. Requirements returning Swift types receive the
/// arena to allocate in; `async` requirements return a CompletableFuture.
class KotlinProfileClient(
private val fail: Boolean = false,
private val pending: CompletableFuture<Profile>? = null,
) : ProfileClient {
override fun displayName(id: String): String = "user-$id"

override fun profile(id: String, swiftArena: SwiftArena): Profile = Profile.init("profile-$id", swiftArena)

override fun fetch(id: String, swiftArena: SwiftArena): CompletableFuture<Profile> =
pending
?: if (fail) {
CompletableFuture.failedFuture(IllegalStateException("offline"))
} else {
CompletableFuture.supplyAsync { Profile.init("fetched-$id", swiftArena) }
}
}

class ProtocolsTest {
@Test
fun syncRequirement() {
assertEquals("user-1", ProfileService.init(KotlinProfileClient()).title("1"))
}

@Test
fun requirementReturningAStruct() {
assertEquals("profile-1", ProfileService.init(KotlinProfileClient()).name("1"))
}

@Test
fun asyncRequirement() = runBlocking {
assertEquals("fetched-1", ProfileService.init(KotlinProfileClient()).load("1").await())
}

@Test
fun asyncRequirementFailure() = runBlocking {
assertFailsWith<Exception> { ProfileService.init(KotlinProfileClient(fail = true)).load("1").await() }
Unit
}

@Test
fun cancellingTheSwiftTaskCancelsTheFuture() = runBlocking {
val pending = CompletableFuture<Profile>()
val endedWithoutValue = ProfileService.init(KotlinProfileClient(pending = pending)).fetchThenCancel("1").await()
assertTrue(endedWithoutValue)
assertTrue(pending.isCancelled)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift.org project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//


/// `async` requirements implemented in Java return a `CompletableFuture`, which Swift awaits.
public protocol AsyncCallbackProtocol {
func fetchValue(_ input: Int64) async -> Int64
func fetchObject(_ input: Int64) async throws -> MySwiftClass
}

public func callAsyncCallbacks(_ callbacks: some AsyncCallbackProtocol, input: Int64) async throws -> Int64 {
let value = await callbacks.fetchValue(input)
let object = try await callbacks.fetchObject(input)
return value + object.x
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2024 Apple Inc. and the Swift.org project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.txt for the list of Swift.org project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//

package com.example.swift;

import org.junit.jupiter.api.Test;
import org.swift.swiftkit.core.SwiftArena;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

import static org.junit.jupiter.api.Assertions.*;

public class AsyncProtocolCallbacksTest {
static class JavaAsyncCallbacks implements AsyncCallbackProtocol {
private final boolean fail;

JavaAsyncCallbacks(boolean fail) {
this.fail = fail;
}

@Override
public CompletableFuture<Long> fetchValue(long input) {
return CompletableFuture.supplyAsync(() -> input * 2);
}

@Override
public CompletableFuture<MySwiftClass> fetchObject(long input, SwiftArena swiftArena) {
if (fail) {
return CompletableFuture.failedFuture(new IllegalStateException("offline"));
}
return CompletableFuture.supplyAsync(() -> MySwiftClass.init(input, 0, swiftArena));
}
}

@Test
void asyncRequirementsAreAwaited() throws Exception {
// 5 * 2 from fetchValue, plus x = 5 from fetchObject
assertEquals(15, MySwiftLibrary.callAsyncCallbacks(new JavaAsyncCallbacks(false), 5).get());
}

@Test
void asyncRequirementFailurePropagates() {
var future = MySwiftLibrary.callAsyncCallbacks(new JavaAsyncCallbacks(true), 5);
assertThrows(ExecutionException.class, future::get);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ extension JNISwift2JavaGenerator {
}

do {
let translator = JavaInterfaceProtocolWrapperGenerator()
let translator = JavaInterfaceProtocolWrapperGenerator(asyncFuncMode: config.effectiveAsyncFuncMode)
wrappers[type] = try translator.generate(for: type)
} catch {
self.logger.warning("Failed to generate protocol wrapper for: '\(type.swiftNominal.qualifiedName)'; \(error)")
Expand Down Expand Up @@ -104,6 +104,8 @@ extension JNISwift2JavaGenerator {
}

struct JavaInterfaceProtocolWrapperGenerator {
var asyncFuncMode: JExtractAsyncFuncMode = .default

func generate(for type: ExtractedNominalType) throws -> JavaInterfaceSwiftWrapper {
if !type.initializers.isEmpty
|| type.methods.contains(where: \.isStatic)
Expand Down Expand Up @@ -139,6 +141,30 @@ extension JNISwift2JavaGenerator {
)
}

/// An `async` requirement implemented in Java returns a `CompletableFuture`, which the Swift
/// wrapper awaits; its value arrives as a plain Java object. Supported results: `Void`, types
/// translated directly (unboxed via `JavaBoxable`), and jextracted types.
private func validateAsyncRequirement(_ signature: SwiftFunctionSignature) throws {
guard asyncFuncMode == .completableFuture else {
// A `java.util.concurrent.Future` can't notify Swift when it completes.
throw JavaTranslationError.asyncRequirementNeedsCompletableFuture
}
let resultType = signature.result.type
if resultType.isVoid {
return
}
if case .nominal(let nominal) = resultType, let known = nominal.nominalTypeDecl.knownTypeKind {
if known.isDirectlyTranslatedToWrapJava && known != .uint16 {
return
}
throw JavaTranslationError.unsupportedSwiftType(resultType)
}
if case .nominal = resultType {
return
}
throw JavaTranslationError.unsupportedSwiftType(resultType)
}

/// Generates a synthetic closure function translation.
/// This treats the closure as if it were a protocol with a single `apply` method,
/// allowing it to use the same conversion infrastructure for optionals, arrays, etc.
Expand Down Expand Up @@ -169,6 +195,10 @@ extension JNISwift2JavaGenerator {
}

private func translate(function: ExtractedFunc) throws -> JavaInterfaceSwiftWrapper.Function {
if function.functionSignature.isAsync {
try validateAsyncRequirement(function.functionSignature)
}

let parameters = try function.functionSignature.parameters.map {
try self.translateParameter($0)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -933,7 +933,8 @@ extension JNISwift2JavaGenerator {
}
generics.append((name, extends))
}
.map { "\($0) extends \($1.compactMap(\.className).joined(separator: " & "))" }
// Fully qualified, so a protocol from another module (another Java package) resolves.
.map { "\($0) extends \($1.compactMap(\.fullyQualifiedClassName).joined(separator: " & "))" }
.joined(separator: .comma)

if !generics.isEmpty {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ extension JNISwift2JavaGenerator {
knownTypes: SwiftKnownTypes(symbolTable: lookupContext.symbolTable),
protocolWrappers: self.interfaceProtocolWrappers,
logger: self.logger,
javaCallbackModules: self.javaCallbackModules,
errorTypes: self.swiftErrorTypes,
javaIdentifiers: self.currentJavaIdentifiers
)
Expand Down Expand Up @@ -115,6 +116,7 @@ extension JNISwift2JavaGenerator {
knownTypes: SwiftKnownTypes(symbolTable: lookupContext.symbolTable),
protocolWrappers: self.interfaceProtocolWrappers,
logger: self.logger,
javaCallbackModules: self.javaCallbackModules,
errorTypes: self.swiftErrorTypes,
javaIdentifiers: self.currentJavaIdentifiers,
)
Expand All @@ -137,6 +139,7 @@ extension JNISwift2JavaGenerator {
var knownTypes: SwiftKnownTypes
let protocolWrappers: [ExtractedNominalType: JavaInterfaceSwiftWrapper]
let logger: Logger
var javaCallbackModules: Set<String> = []
/// Module-qualified identities of extracted types that are surfaced to Java as `SwiftError` exceptions.
let errorTypes: Set<SwiftNominalIdentity>
var javaIdentifiers: JavaIdentifierFactory
Expand Down Expand Up @@ -210,6 +213,7 @@ extension JNISwift2JavaGenerator {
knownTypes: self.knownTypes,
protocolWrappers: self.protocolWrappers,
logger: self.logger,
javaCallbackModules: self.javaCallbackModules,
)

// Types with no parent will be outputted inside a "module" class.
Expand Down Expand Up @@ -2271,6 +2275,9 @@ extension JNISwift2JavaGenerator {
/// `inout` parameters have no Java equivalent yet.
case inoutParametersNotSupported

/// Java can implement `async` protocol requirements only in `completableFuture` async mode.
case asyncRequirementNeedsCompletableFuture

// FIXME: Remove once we support protocol variables
case protocolVariablesNotSupported

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ extension JNISwift2JavaGenerator {
var knownTypes: SwiftKnownTypes
let protocolWrappers: [ExtractedNominalType: JavaInterfaceSwiftWrapper]
let logger: Logger
var javaCallbackModules: Set<String> = []

/// Translates a Swift function into the native JNI method signature.
func translate(
Expand Down Expand Up @@ -505,6 +506,7 @@ extension JNISwift2JavaGenerator {
// Swift wrappers for all the protocol types.
let allowsJavaImplementations = protocolTypes.allSatisfy { protocolType in
self.protocolWrappers.contains(where: { $0.value.protocolType == protocolType })
|| javaCallbackModules.contains(protocolType.nominalTypeDecl.moduleName)
}

return NativeParameter(
Expand Down
Loading
Loading