diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index bdf459660..30f7b0b2a 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -278,6 +278,7 @@ jobs: 'SwiftAndJavaJarFFMSampleLib', 'SwiftJavaExtractFFMSampleApp', 'SwiftJavaExtractJNISampleApp', + 'SwiftJavaExtractJNIKotlinSampleApp', ] container: image: ${{ (contains(matrix.swift_version, 'nightly') && 'swiftlang/swift') || 'swift' }}:${{ matrix.swift_version }}-${{ matrix.os_version }} @@ -315,6 +316,7 @@ jobs: 'SwiftAndJavaJarFFMSampleLib', 'SwiftJavaExtractFFMSampleApp', 'SwiftJavaExtractJNISampleApp', + 'SwiftJavaExtractJNIKotlinSampleApp', ] steps: - uses: actions/checkout@v7 diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Package.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Package.swift new file mode 100644 index 000000000..4c004041b --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Package.swift @@ -0,0 +1,53 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +// Swift 6 language mode, with the generated bindings used from Kotlin (src/test/kotlin). +// KotlinSampleModels is a separate module so types and protocols cross module boundaries. +let package = Package( + name: "SwiftJavaExtractJNIKotlinSampleApp", + platforms: [ + .macOS(.v15) + ], + products: [ + .library(name: "KotlinSampleModels", type: .dynamic, targets: ["KotlinSampleModels"]), + .library(name: "KotlinSampleLibrary", type: .dynamic, targets: ["KotlinSampleLibrary"]), + ], + dependencies: [ + .package(name: "swift-java", path: "../../") + ], + targets: [ + .target( + name: "KotlinSampleModels", + dependencies: [ + .product(name: "SwiftJava", package: "swift-java") + ], + exclude: [ + "swift-java.config" + ], + swiftSettings: [ + .swiftLanguageMode(.v6) + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] + ), + .target( + name: "KotlinSampleLibrary", + dependencies: [ + .product(name: "SwiftJava", package: "swift-java"), + "KotlinSampleModels", + ], + exclude: [ + "swift-java.config" + ], + swiftSettings: [ + .swiftLanguageMode(.v6) + ], + plugins: [ + .plugin(name: "JExtractSwiftPlugin", package: "swift-java") + ] + ), + ] +) diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Baseline.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Baseline.swift new file mode 100644 index 000000000..fb0cad6d9 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/Baseline.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +public struct Item: Hashable, Sendable { + public var id: String + public var count: Int64 + + public init(id: String, count: Int64) { + self.id = id + self.count = count + } +} + +public enum Route: Sendable { + case home + case detail(Item) +} + +public enum StoreError: Error { + case notFound +} + +public final class Store: Sendable { + public init() {} + + public func item(id: String) throws -> Item { + if id.isEmpty { throw StoreError.notFound } + return Item(id: id, count: 1) + } + + public func load(id: String) async throws -> Item { + try item(id: id) + } + + public func route(for item: Item) -> Route { + .detail(item) + } + + /// Takes a type from another module. + public func label(_ item: Item, tag: Tag) -> String { + "\(tag.name):\(item.id)" + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/MainActorViewModel.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/MainActorViewModel.swift new file mode 100644 index 000000000..396cfc8cc --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/MainActorViewModel.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// View models are main-actor isolated. Their Java bindings stay synchronous and must be called +/// on the main thread (Compose's UI thread on Android). +@MainActor +public final class CounterViewModel { + public private(set) var count: Int64 = 0 + public var title: String + + public init(title: String) { + self.title = title + } + + public func increment() { + count += 1 + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config new file mode 100644 index 000000000..12c9d9e63 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleLibrary/swift-java.config @@ -0,0 +1,5 @@ +{ + "javaPackage": "com.example.kotlin", + "mode": "jni", + "memoryManagementMode": "allowGlobalAutomatic" +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/Tag.swift b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/Tag.swift new file mode 100644 index 000000000..29ee0a4c9 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/Tag.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// +// 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 value type declared in its own module and used from another. +public struct Tag: Sendable { + public var name: String + + public init(name: String) { + self.name = name + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/swift-java.config b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/swift-java.config new file mode 100644 index 000000000..53b179ce0 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/Sources/KotlinSampleModels/swift-java.config @@ -0,0 +1,5 @@ +{ + "javaPackage": "com.example.kotlin.models", + "mode": "jni", + "memoryManagementMode": "allowGlobalAutomatic" +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/build.gradle.kts b/Samples/SwiftJavaExtractJNIKotlinSampleApp/build.gradle.kts new file mode 100644 index 000000000..349cdd89f --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/build.gradle.kts @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +import java.time.Duration +import utilities.javaLibraryPaths +import utilities.jextractGeneratedJavaDirs +import utilities.registerJextractTask + +// The generated Java bindings, used from Kotlin: src/test/kotlin has one test file per feature. +plugins { + id("build-logic.java-common-conventions") + kotlin("jvm") version "2.4.21" +} + +group = "org.swift.swiftkit" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of((gradle.extra.properties["swiftJavaJdk"] as? Int) ?: 17)) + } +} + +val jextract = registerJextractTask { + // FIXME: disable prebuilts until swift-syntax isn't broken on 6.2 anymore: https://github.com/swiftlang/swift-java/issues/418 + val cmdArgs = mutableListOf("build", "--disable-experimental-prebuilts", "--disable-sandbox") + if (project.hasProperty("swiftSdk")) { + cmdArgs.add("--swift-sdk") + cmdArgs.add(project.property("swiftSdk").toString()) + } + cmdArgs +} + +sourceSets { + main { + java { + srcDir(jextract) + jextractGeneratedJavaDirs().forEach { srcDir(it) } + } + } +} + +tasks.build { + dependsOn(jextract) +} + +dependencies { + implementation(projects.swiftKitCore) + + testImplementation(kotlin("test-junit5")) + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") + testRuntimeOnly(libs.junit.platform.launcher) // TODO: workaround for not finding junit: https://github.com/gradle/gradle/issues/34512 +} + +tasks.named("test") { + useJUnitPlatform() + // A deadlocked test fails instead of stalling the build. + timeout.set(Duration.ofMinutes(2)) + + jvmArgs( + "--enable-native-access=ALL-UNNAMED", + // Include the library paths where our dylibs are that we want to load and call + "-Djava.library.path=" + javaLibraryPaths(project.projectDir).joinToString(File.pathSeparator), + ) + + // Run tests on the process's first thread, which Swift treats as the main actor's thread (as on + // Android's UI thread), so main-actor isolated APIs can be called. macOS only. + if (System.getProperty("os.name").startsWith("Mac")) { + jvmArgs("-XstartOnFirstThread") + } + + testLogging { + events("failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/ci-validate.sh b/Samples/SwiftJavaExtractJNIKotlinSampleApp/ci-validate.sh new file mode 100755 index 000000000..de00ef16c --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/ci-validate.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -x +set -e + +# WORKAROUND: prebuilts broken on Swift 6.2.1 and Linux and tests using macros https://github.com/swiftlang/swift-java/issues/418 +if [ "$(uname)" = "Darwin" ]; then + DISABLE_EXPERIMENTAL_PREBUILTS='' +else + DISABLE_EXPERIMENTAL_PREBUILTS='--disable-experimental-prebuilts' +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 + +./gradlew test diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradle.properties b/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradle.properties new file mode 120000 index 000000000..7677fb73b --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradle.properties @@ -0,0 +1 @@ +../gradle.properties \ No newline at end of file diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradlew b/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradlew new file mode 120000 index 000000000..343e0d2ca --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradlew @@ -0,0 +1 @@ +../../gradlew \ No newline at end of file diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradlew.bat b/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradlew.bat new file mode 120000 index 000000000..cb5a94645 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/gradlew.bat @@ -0,0 +1 @@ +../../gradlew.bat \ No newline at end of file diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/BaselineTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/BaselineTest.kt new file mode 100644 index 000000000..acd8e7e86 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/BaselineTest.kt @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// +// 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.Tag +import kotlinx.coroutines.future.await +import kotlinx.coroutines.runBlocking +import org.swift.swiftkit.core.SwiftArena +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +class BaselineTest { + @Test + fun structRoundTrip() { + val item = Item.init("a", 2) + assertEquals("a", item.id) + assertEquals(2, item.count) + assertEquals(Item.init("a", 2), item) + assertEquals(Item.init("a", 2).hashCode(), item.hashCode()) + } + + @Test + fun throwsBecomesAnException() { + val store = Store.init() + assertEquals("x", store.item("x").id) + assertFailsWith { store.item("") } + } + + @Test + fun asyncIsAFuture() = runBlocking { + assertEquals("x", Store.init().load("x").await().id) + } + + @Test + fun enumWithPayload() { + val route = Store.init().route(Item.init("a", 1)) + val case = route.getCase(SwiftArena.ofAuto()) + assertIs(case) + assertEquals("a", case.arg0.id) + } + + @Test + fun typeFromAnotherModule() { + assertEquals("tag:a", Store.init().label(Item.init("a", 1), Tag.init("tag"))) + } +} diff --git a/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/MainActorTest.kt b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/MainActorTest.kt new file mode 100644 index 000000000..90d047ab7 --- /dev/null +++ b/Samples/SwiftJavaExtractJNIKotlinSampleApp/src/test/kotlin/com/example/kotlin/MainActorTest.kt @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// +// 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 org.junit.jupiter.api.condition.EnabledOnOs +import org.junit.jupiter.api.condition.OS +import kotlin.test.Test +import kotlin.test.assertEquals + +// Main-actor members check that they run on the main thread. Only on macOS can the JVM test thread +// be the process's main thread (`-XstartOnFirstThread`), as Android's UI thread is. +@EnabledOnOs(OS.MAC) +class MainActorTest { + @Test + fun propertiesAndMethods() { + val vm = CounterViewModel.init("Counter") + assertEquals("Counter", vm.title) + vm.increment() + vm.increment() + assertEquals(2, vm.count) + vm.title = "Renamed" + assertEquals("Renamed", vm.title) + } +}