diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ScreenCallbacks.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ScreenCallbacks.swift new file mode 100644 index 000000000..6be5228ca --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/ScreenCallbacks.swift @@ -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 +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ScreenCallbacksTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ScreenCallbacksTest.kt new file mode 100644 index 000000000..7f12f6102 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/ScreenCallbacksTest.kt @@ -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 { failed.get() } + assertIs(error.cause) + } + + private fun await(future: CompletableFuture<*>) { + val deadline = System.currentTimeMillis() + 5_000 + while (!future.isDone && System.currentTimeMillis() < deadline) MainLoop.run(0.01) + } +} diff --git a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift index 66f57da40..7a5dbfb2e 100644 --- a/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift +++ b/Sources/JExtractSwiftLib/JNI/JNISwift2JavaGenerator+NativeTranslation.swift @@ -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: [] ) } @@ -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) @@ -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 diff --git a/Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift b/Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift index 60ddd70dd..15bbac117 100644 --- a/Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift +++ b/Sources/SwiftJavaRuntimeSupport/SwiftClosureBridge.swift @@ -58,3 +58,65 @@ public func Java_org_swift_swiftkit_core_SwiftClosure__00024release( ) { Unmanaged.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: "", 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 +} diff --git a/Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift b/Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift index 94da1874c..eec545927 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIClosureResultsTests.swift @@ -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.toJavaObject(callee$(JavaBoxableBridge.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.fromJavaObject(_swiftClosureArgument(arguments$, 0, in: environment), in: environment) + return _swiftClosureFuture(in: environment) { environment in + return JavaBoxableBridge.toJavaObject(try await callee$(argument0$), in: environment) + } + } + """ + ] + ) + } + + @Test + func throwingClosureResult_isSkipped() throws { + try assertOutput( + input: "public func makeHandler() -> () throws -> Void {}", .jni, .java, detectChunkByInitialLines: 1,