Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .github/workflows/swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ jobs:
strategy:
fail-fast: false
matrix:
swift: ['6.2.3', 'nightly-6.3']
# Package manifests require swift-tools 6.3.
swift: ['6.3.3']
arch: ['aarch64', 'x86_64', 'armv7']
sdk: ['28', '29', '31', '33']
runs-on: macos-15
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# Java is required by android-commandlinetools (skip android sdk install)
# and by the swift-java jextract plugin.
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: "Build Swift Package for Android"
run: |
brew install skiptools/skip/skip || (brew update && brew install skiptools/skip/skip)
Expand Down
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,10 @@ Package.resolved
# Android
*.so
.gradle
.idea
.idea
# GenerateBluetoothDefinitions plugin artifacts leaked into cwd by --disable-sandbox builds
*-2.d
*-2.dia
*-2.swiftdeps
*-2.swiftmodule
.kotlin/
51 changes: 48 additions & 3 deletions Demo/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let package = Package(
.library(
name: "SwiftAndroidApp",
type: .dynamic,
targets: ["SwiftAndroidApp"]
targets: ["BluetoothDemoBridge"]
),
],
dependencies: [
Expand All @@ -21,23 +21,68 @@ let package = Package(
url: "https://github.com/PureSwift/Android.git",
branch: "master"
),
.package(
url: "https://github.com/swiftlang/swift-java.git",
branch: "main"
),
.package(
url: "https://github.com/swift-android-sdk/swift-android-native.git",
from: "2.1.0"
),
.package(
url: "https://github.com/PureSwift/GATT.git",
from: "4.0.0"
),
.package(
url: "https://github.com/PureSwift/Bluetooth.git",
from: "8.0.0"
)
],
targets: [
.target(
name: "SwiftAndroidApp",
name: "BluetoothDemoBridge",
dependencies: [
.product(
name: "AndroidBluetooth",
package: "AndroidBluetooth"
),
.product(
name: "AndroidBluetoothBridge",
package: "AndroidBluetooth"
),
.product(
name: "GATT",
package: "GATT"
),
.product(
name: "Bluetooth",
package: "Bluetooth"
),
.product(
name: "SwiftJava",
package: "swift-java"
),
.product(
name: "AndroidKit",
package: "Android"
),
.product(
name: "AndroidContext",
package: "swift-android-native"
)
],
path: "./app/src/main/swift",
path: "./app/src/main/swift-bridge/BluetoothDemoBridge",
exclude: [
"swift-java.config"
],
swiftSettings: [
.swiftLanguageMode(.v5)
],
plugins: [
.plugin(
name: "JExtractSwiftPlugin",
package: "swift-java"
)
]
)
]
Expand Down
137 changes: 123 additions & 14 deletions Demo/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,111 @@ plugins {
alias(libs.plugins.kotlin.compose)
}

// ---------------------------------------------------------------------------
// swift-java (jextract, JNI mode) integration
//
// The Swift package graph (AndroidBluetooth + AndroidBluetoothBridge +
// BluetoothDemoBridge) is cross-compiled to native `.so`s with the official
// Swift Android SDK; the `JExtractSwiftPlugin` emits the Java JNI bindings as
// a side effect of `swift build`. Gradle then adds the generated Java +
// SwiftKitCore runtime sources to the source set and stages every required
// `.so` into `jniLibs`.
// ---------------------------------------------------------------------------

val swiftPackageRoot: File = rootProject.projectDir
val androidTriple = "aarch64-unknown-linux-android28"
val swiftAbi = "arm64-v8a"
val userHome: String = System.getProperty("user.home")

// Configuration the Swift package is cross-compiled with, independent of the
// Android build type. Override with `-PswiftBuildConfig=release`.
val swiftBuildConfig: String = (findProperty("swiftBuildConfig") as String?) ?: "debug"

// Locates both the toolchain and the matching Android SDK artifactbundle.
val swiftToolchainVersion: String = (findProperty("swiftToolchainVersion") as String?) ?: "6.3.3"

// The Android SDK's prebuilt Swift modules require the matching swift.org
// toolchain. Override with `-PswiftBin=/path/to/swift` if installed elsewhere.
val swiftBin: String = (findProperty("swiftBin") as String?)
?: "$userHome/Library/Developer/Toolchains/swift-$swiftToolchainVersion-RELEASE.xctoolchain/usr/bin/swift"

// swift-java's `enableJavaCallbacks` feature runs its own internal Gradle
// sub-build to compile the generated Java callback interfaces, requiring a
// modern JDK — independent of whatever JDK runs *this* Gradle build.
// Override with `-PcallbacksJdkHome=/path/to/jdk`.
val callbacksJdkHome: String = (findProperty("callbacksJdkHome") as String?)
?: "/opt/homebrew/opt/openjdk@25"

// SwiftPM plugin output convention: `outputs/<package-dir-lowercased>/<TargetName>/...`.
// Both bridge targets generate Java: the AndroidBluetooth library's callback
// bridge and the demo's UI-facing bridge.
val generatedLibraryJavaDir = File(swiftPackageRoot, ".build/plugins/outputs/androidbluetooth/AndroidBluetoothBridge/destination/JExtractSwiftPlugin/src/generated/java")
val generatedDemoJavaDir = File(swiftPackageRoot, ".build/plugins/outputs/demo/BluetoothDemoBridge/destination/JExtractSwiftPlugin/src/generated/java")
val swiftKitCoreDir = File(swiftPackageRoot, ".build/checkouts/swift-java/SwiftKitCore/src/main/java")
val swiftBuildDir = File(swiftPackageRoot, ".build/$androidTriple/$swiftBuildConfig")
val swiftAndroidRuntimeDir = File(
(findProperty("swiftAndroidRuntimeDir") as String?)
?: "$userHome/Library/org.swift.swiftpm/swift-sdks/swift-${swiftToolchainVersion}-RELEASE_android.artifactbundle/swift-android/swift-resources/usr/lib/swift-aarch64/android"
)

// Supplies libc++_shared.so, which every Swift Android binary links against.
val swiftAndroidSysroot = File(
(findProperty("swiftAndroidSysroot") as String?)
?: "$userHome/Library/org.swift.swiftpm/swift-sdks/swift-${swiftToolchainVersion}-RELEASE_android.artifactbundle/swift-android/ndk-sysroot"
)

// Cross-compile the Swift package + generate the JNI Java bindings.
val jextract = tasks.register<Exec>("jextract") {
workingDir = swiftPackageRoot
environment("JAVA_HOME", callbacksJdkHome)
commandLine(
swiftBin, "build",
"--swift-sdk", androidTriple,
"-c", swiftBuildConfig,
"--disable-sandbox"
)
outputs.dir(generatedLibraryJavaDir)
outputs.dir(generatedDemoJavaDir)
outputs.file(File(swiftBuildDir, "libSwiftAndroidApp.so"))
// `swift build` is incremental itself; let it decide what is stale.
outputs.upToDateWhen { false }
}

// SwiftKitCore is consumed as source (swift-java is not published to Maven),
// minus two annotations that pull in `jdk.jfr` (Java Flight Recorder), which
// is unavailable on Android. `Unsigned` (which the generated bindings DO
// reference) is replaced by an Android-compatible copy in `src/main/java`.
val patchedSwiftKitCoreDir = layout.buildDirectory.dir("swiftkitcore-java")
val stageSwiftKitCore = tasks.register<Sync>("stageSwiftKitCore") {
dependsOn(jextract)
from(swiftKitCoreDir)
exclude(
"org/swift/swiftkit/core/annotations/ThreadSafe.java",
"org/swift/swiftkit/core/annotations/Unsigned.java"
)
into(patchedSwiftKitCoreDir)
}

// Stage the cross-compiled libraries, the swift-java runtime, the Swift
// Android runtime and libc++_shared into jniLibs so they end up in the APK.
val stageJniLibs = tasks.register<Copy>("stageJniLibs") {
dependsOn(jextract)
into(layout.projectDirectory.dir("src/main/jniLibs/$swiftAbi"))
from(swiftBuildDir) {
// Every dynamic library product built by the package graph — anything
// less leaves a dangling `dlopen` at runtime.
include("*.so")
}
from(swiftAndroidRuntimeDir) {
include("*.so")
// Test-only runtime libraries are not needed by the app.
exclude("*Testing*", "libXCTest.so")
}
from(File(swiftAndroidSysroot, "usr/lib/aarch64-linux-android")) {
include("libc++_shared.so")
}
}

android {
namespace = "com.pureswift.swiftandroid"
compileSdk = 35
Expand All @@ -16,7 +121,7 @@ android {
versionName = "1.0"
ndk {
//noinspection ChromeOsAbiSupport
abiFilters += listOf("arm64-v8a")
abiFilters += listOf(swiftAbi)
}
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand All @@ -31,15 +136,21 @@ android {
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "11"
jvmTarget = "17"
}
buildFeatures {
compose = true
}

// Generated JNI bindings + the (patched) SwiftKitCore Java runtime.
sourceSets["main"].java.srcDir(generatedLibraryJavaDir)
sourceSets["main"].java.srcDir(generatedDemoJavaDir)
sourceSets["main"].java.srcDir(patchedSwiftKitCoreDir)

packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}")
Expand All @@ -54,16 +165,16 @@ android {
}
}

// Compile native Swift code for the demo app with `skip android build`.
val buildSwift by tasks.registering(Exec::class) {
group = "build"
description = "Build native Swift sources for Android"
workingDir(rootProject.projectDir)
commandLine("bash", "build-swift.sh")
// Ensure Swift is built + bindings generated + libs staged before Java or
// Kotlin compiles (Kotlin also consumes the generated Java bindings).
tasks.withType<JavaCompile>().configureEach {
dependsOn(jextract, stageSwiftKitCore)
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
dependsOn(jextract, stageSwiftKitCore)
}

tasks.named("preBuild") {
dependsOn(buildSwift)
dependsOn(stageJniLibs)
}

dependencies {
Expand All @@ -75,8 +186,6 @@ dependencies {
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.recyclerview)
implementation(libs.androidx.navigation.runtime)
implementation(libs.androidx.material3)
implementation(libs.material)
testImplementation(libs.junit)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
//
// 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 org.swift.swiftkit.core.annotations;

import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.*;

/**
* Value is of an unsigned numeric type.
* <p>
* Android-compatible copy of SwiftKitCore's {@code Unsigned} annotation: the
* upstream source carries {@code jdk.jfr} annotations that are unavailable on
* Android, so the original is excluded from the source set and replaced by
* this one.
*/
@Documented
@Target({TYPE_USE, PARAMETER, FIELD, METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Unsigned {
}
25 changes: 2 additions & 23 deletions Demo/app/src/main/kotlin/com/pureswift/swiftandroid/Application.kt
Original file line number Diff line number Diff line change
@@ -1,29 +1,8 @@
package com.pureswift.swiftandroid

import com.example.swift.HelloSubclass

class Application: android.app.Application() {
class Application : android.app.Application() {

init {
NativeLibrary.shared()
}

override fun onCreate() {
super.onCreate()
onCreateSwift()
}

private external fun onCreateSwift()

override fun onTerminate() {
super.onTerminate()
onTerminateSwift()
}

private external fun onTerminateSwift()

fun sayHello() {
val result = HelloSubclass("Swift").sayHello(17, 25)
println("sayHello(17, 25) = $result")
}
}
}
Loading
Loading