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,55 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//

/// Closures a view model hands to its screen. The UI calls the main-actor ones on the main thread;
/// async ones return a `CompletableFuture`.
@MainActor
public final class ScreenModel {
public private(set) var events: [String] = []

public init() {}

public var onTap: @MainActor () -> Void {
{ [unowned self] in events.append("tap") }
}

public var onSelect: @MainActor (String) -> Void {
{ [unowned self] row in events.append("select \(row)") }
}

public var title: @MainActor () -> String {
{ "Markets" }
}

public var onDismiss: (@MainActor () -> Void)? {
{ [unowned self] in events.append("dismiss") }
}

public var count: @Sendable (String) async -> Int64 {
{ text in Int64(text.count) }
}

public var save: @MainActor (String) async throws -> Void {
{ [unowned self] name in
guard !name.isEmpty else {
throw ScreenError.emptyName
}
events.append("save \(name)")
}
}
}

public enum ScreenError: Error {
case emptyName
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//===----------------------------------------------------------------------===//
//
// 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 java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
import org.junit.jupiter.api.condition.EnabledOnOs
import org.junit.jupiter.api.condition.OS
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs

// Closures read from Swift: main-actor ones are called on the main thread, as Compose calls a view
// model's callbacks; async ones return a CompletableFuture. Like MainActorTest, this needs the JVM
// test thread to be the main thread, which only macOS allows.
@EnabledOnOs(OS.MAC)
class ScreenCallbacksTest {
@Test
fun mainActorClosures() {
val screen = ScreenModel.init()
screen.onTap.apply()
screen.onSelect.apply("BTC")
assertEquals("Markets", screen.title.apply())
screen.onDismiss?.apply()
assertEquals(listOf("tap", "select BTC", "dismiss"), screen.events.toList())
}

@Test
fun asyncClosures() {
val screen = ScreenModel.init()
assertEquals(3L, screen.count.apply("abc").get())

// `save` runs on the main actor: pump the main loop rather than block the main thread on it.
val saved = screen.save.apply("draft")
await(saved)
assertEquals(listOf("save draft"), screen.events.toList())

val failed = screen.save.apply("")
await(failed)
val error = assertFailsWith<ExecutionException> { failed.get() }
assertIs<ScreenError>(error.cause)
}

private fun await(future: CompletableFuture<*>) {
val deadline = System.currentTimeMillis() + 5_000
while (!future.isDone && System.currentTimeMillis() < deadline) MainLoop.run(0.01)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1354,15 +1354,22 @@ extension JNISwift2JavaGenerator {

/// A returned closure, as the handle of a Swift closure Java calls with boxed arguments; 0 for `nil`.
func translateSwiftClosureResult(_ functionType: SwiftFunctionType, isOptional: Bool) throws -> NativeResult {
// Java calls it synchronously, on any thread.
if functionType.isAsync || functionType.isThrowing || functionType.isMainActorIsolated {
// A synchronous call from Java has no way to receive a thrown error; an async one completes
// its future with it.
if functionType.isThrowing && !functionType.isAsync {
throw JavaTranslationError.unsupportedSwiftType(.function(functionType))
}
let argumentBridges = try functionType.parameters.map { try bridgeTypeName(for: $0.type) }
let resultBridge = functionType.resultType.isVoid ? nil : try bridgeTypeName(for: functionType.resultType)
return NativeResult(
javaType: .long,
conversion: .swiftClosure(argumentBridges: argumentBridges, resultBridge: resultBridge, isOptional: isOptional),
conversion: .swiftClosure(
argumentBridges: argumentBridges,
resultBridge: resultBridge,
isOptional: isOptional,
isMainActor: functionType.isMainActorIsolated,
effects: functionType.isAsync ? (functionType.isThrowing ? "try await " : "await ") : nil
),
outParameters: []
)
}
Expand Down Expand Up @@ -1543,7 +1550,15 @@ extension JNISwift2JavaGenerator {

/// The handle of a new Swift closure calling the closure value, which converts the boxed Java arguments
/// and result with these `JobjectBridge`s (no result bridge for `Void`).
case swiftClosure(argumentBridges: [String], resultBridge: String?, isOptional: Bool)
/// A closure returned to Java. `effects` is the `await` or `try await` that calls an async closure,
/// `nil` for a synchronous one.
case swiftClosure(
argumentBridges: [String],
resultBridge: String?,
isOptional: Bool,
isMainActor: Bool,
effects: String?
)

indirect case optionalLowering(NativeSwiftConversionStep, discriminatorName: String, valueName: String)

Expand Down Expand Up @@ -1996,20 +2011,41 @@ extension JNISwift2JavaGenerator {
case .nilOrClosure(let inner):
return "\(placeholder) == nil ? nil : \(inner.render(&printer, placeholder))"

case .swiftClosure(let argumentBridges, let resultBridge, let isOptional):
case .swiftClosure(let argumentBridges, let resultBridge, let isOptional, let isMainActor, let effects):
printer.print("let closure$ = \(placeholder)")
let arguments = argumentBridges.enumerated().map { index, bridge in
"\(bridge).fromJavaObject(_swiftClosureArgument(arguments$, \(index), in: environment), in: environment)"
}
let call = "closure$(\(arguments.joined(separator: .comma)))"
let body =
if let resultBridge {
["return \(resultBridge).toJavaObject(\(call), in: environment)"]
} else {
[call, "return nil"]
}
let body: [String]
if let effects {
// Convert the arguments on the calling thread, then await the closure in a task whose
// future Java gets back. The closure and its arguments move into that task.
let argumentNames = arguments.indices.map { "argument\($0)$" }
let call = "\(effects)callee$(\(argumentNames.joined(separator: .comma)))"
let result =
if let resultBridge {
[" return \(resultBridge).toJavaObject(\(call), in: environment)"]
} else {
[" \(call)", " return nil"]
}
body =
["nonisolated(unsafe) let callee$ = closure$"]
+ zip(argumentNames, arguments).map { "nonisolated(unsafe) let \($0) = \($1)" }
+ ["return _swiftClosureFuture(in: environment) { environment in"] + result + ["}"]
} else {
let callee = isMainActor ? "callee$" : "closure$"
let call = "\(callee)(\(arguments.joined(separator: .comma)))"
let result =
if let resultBridge {
["return \(resultBridge).toJavaObject(\(call), in: environment)"]
} else {
[call, "return nil"]
}
body = (isMainActor ? ["nonisolated(unsafe) let callee$ = closure$"] : []) + result
}
let maker = isMainActor && effects == nil ? "_makeMainActorSwiftClosure" : "_makeSwiftClosure"
let make =
(["_makeSwiftClosure { arguments$, environment in"] + body.map { " \($0)" } + ["}"])
(["\(maker) { arguments$, environment in"] + body.map { " \($0)" } + ["}"])
.joined(separator: "\n")
return isOptional ? "closure$.map { closure$ in \(make) } ?? 0" : make

Expand Down
62 changes: 62 additions & 0 deletions Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,65 @@ public func Java_org_swift_swiftkit_core_SwiftClosure__00024release(
) {
Unmanaged<SwiftClosureBox>.fromOpaque(UnsafeRawPointer(bitPattern: Int(handle))!).release()
}

/// A Java object reference that a main-actor call hands back to the calling thread.
private struct _UncheckedJavaObject: @unchecked Sendable {
let value: jobject?
}

/// Like `_makeSwiftClosure`, for a closure isolated to the main actor. Java has to call it on the
/// main thread, where Android and Compose run UI code; any other thread traps, as calling a
/// `@MainActor` function from Java does.
public func _makeMainActorSwiftClosure(
_ call: @escaping @MainActor (_ arguments: jobjectArray?, _ environment: JNIEnvironment) -> jobject?
) -> jlong {
_makeSwiftClosure { arguments, environment in
nonisolated(unsafe) let arguments = arguments
nonisolated(unsafe) let environment = environment
return MainActor.assumeIsolated {
_UncheckedJavaObject(value: call(arguments, environment))
}.value
}
}

private let completableFutureInit = _JNIMethodIDCache.Method(name: "<init>", signature: "()V")
private let completableFutureCache = _JNIMethodIDCache(
className: "java/util/concurrent/CompletableFuture",
methods: [completableFutureInit]
)

/// Runs an async closure called from Java in a new task, and returns a `CompletableFuture` that the
/// task completes with the closure's result, as a boxed Java value, or with its error as a `Throwable`.
public func _swiftClosureFuture(
in environment: JNIEnvironment,
_ body: @escaping @Sendable (_ environment: JNIEnvironment) async throws -> jobject?
) -> jobject? {
let future = environment.interface.NewObjectA(
environment,
completableFutureCache.javaClass,
completableFutureCache[completableFutureInit]!,
nil
)
nonisolated(unsafe) let globalFuture = environment.interface.NewGlobalRef(environment, future)
Task {
let environment = try! JavaVirtualMachine.shared().environment()
do {
let result = try await body(environment)
_ = environment.interface.CallBooleanMethodA(
environment,
globalFuture,
_JNIMethodIDCache.CompletableFuture.complete,
[jvalue(l: result)]
)
} catch {
_ = environment.interface.CallBooleanMethodA(
environment,
globalFuture,
_JNIMethodIDCache.CompletableFuture.completeExceptionally,
[jvalue(l: environment.makeJavaThrowable(for: error))]
)
}
environment.interface.DeleteGlobalRef(environment, globalFuture)
}
return future
}
43 changes: 41 additions & 2 deletions Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,9 +154,48 @@ struct JNIClosureResultsTests {
}

@Test
func mainActorClosureResult_isSkipped() throws {
func mainActorClosureResult_runsOnTheMainActor() throws {
try assertOutput(
input: "public func makeHandler() -> @MainActor () -> Void {}",
input: "public func makeHandler() -> @MainActor (String) -> Int64 {}",
.jni,
.swift,
detectChunkByInitialLines: 1,
expectedChunks: [
"""
return _makeMainActorSwiftClosure { arguments$, environment in
nonisolated(unsafe) let callee$ = closure$
return JavaBoxableBridge<Int64>.toJavaObject(callee$(JavaBoxableBridge<String>.fromJavaObject(_swiftClosureArgument(arguments$, 0, in: environment), in: environment)), in: environment)
}
"""
]
)
}

@Test
func asyncClosureResult_returnsAFuture() throws {
try assertOutput(
input: "public func makeLoader() -> @MainActor (String) async throws -> Int64 {}",
.jni,
.swift,
detectChunkByInitialLines: 1,
expectedChunks: [
"""
return _makeSwiftClosure { arguments$, environment in
nonisolated(unsafe) let callee$ = closure$
nonisolated(unsafe) let argument0$ = JavaBoxableBridge<String>.fromJavaObject(_swiftClosureArgument(arguments$, 0, in: environment), in: environment)
return _swiftClosureFuture(in: environment) { environment in
return JavaBoxableBridge<Int64>.toJavaObject(try await callee$(argument0$), in: environment)
}
}
"""
]
)
}

@Test
func throwingClosureResult_isSkipped() throws {
try assertOutput(
input: "public func makeHandler() -> () throws -> Void {}",
.jni,
.java,
detectChunkByInitialLines: 1,
Expand Down
Loading