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
161 changes: 152 additions & 9 deletions Sources/SwiftJava/AndroidSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,164 @@
//
//===----------------------------------------------------------------------===//

#if os(Android) && AndroidCoreLibraryDesugaring
import Synchronization
import SwiftJavaJNICore
#endif

/// Helpers for dealing with Android's [Core Library
/// Desugaring](https://developer.android.com/studio/write/java8-support), which relocates a handful of
/// `java.*` classes to a `j$.*` package for apps whose `minSdk` predates their platform introduction.
public enum AndroidSupport {
/// Performs any known name conversions
/// for types that are desugared on specific Android versions
/// Classes relocated under `j$` by Android core library desugaring.
///
/// Must never contain a `java.lang.*` name. Desugaring does not relocate that package, and `probe`
/// depends on it: the class loader call it makes goes through wrappers that themselves resolve
/// `java.lang` class names, which would re-enter `resolve` if those names were probeable.
static let desugaredClassNames: Set<String> = [
"java.util.Optional",
"java.util.OptionalInt",
"java.util.OptionalLong",
"java.util.OptionalDouble",
]

/// The `j$` name for a desugarable class, or nil if the class is never desugared.
@_spi(Testing)
public static func _desugaredName(forDotted name: String) -> String? {
guard desugaredClassNames.contains(name) else {
return nil
}
return "j$." + name.dropFirst(5) // drop java.
}

/// Rewrites every `L…;` class-name component of a JNI method/field descriptor through `mapping`.
@_spi(Testing)
public static func _rewriteDescriptor(_ descriptor: String, mapping: (String) -> String) -> String {
guard descriptor.contains("L") else {
return descriptor
}

var result = ""
result.reserveCapacity(descriptor.count)
var didRewrite = false

var index = descriptor.startIndex
while index < descriptor.endIndex {
let character = descriptor[index]
if character == "L", let semicolon = descriptor[index...].firstIndex(of: ";") {
let className = String(descriptor[descriptor.index(after: index)..<semicolon])
let mapped = mapping(className)
if mapped != className {
didRewrite = true
}
result += "L"
result += mapped
result += ";"
index = descriptor.index(after: semicolon)
} else {
result.append(character)
index = descriptor.index(after: index)
}
}

return didRewrite ? result : descriptor
}
}

#if os(Android) && AndroidCoreLibraryDesugaring
extension AndroidSupport {
private enum ProbeResult {
case found
case notFound
case undetermined
}

/// dotted original -> dotted resolved (either the original, or its `j$` desugared form).
private static let cache = Mutex<[String: String]>([:])

private static func resolve(dotted name: String) -> String {
guard let candidate = _desugaredName(forDotted: name) else { return name }

if let cached = cache.withLock({ $0[name] }) {
return cached
}

switch probe(candidate) {
case .found:
cache.withLock { $0[name] = candidate }
return candidate
case .notFound:
cache.withLock { $0[name] = name }
return name
case .undetermined:
// Don't cache: the app's class loader might not be ready yet
return name
}
}

/// Probes whether `dotted` (already `j$`-prefixed) is loadable in this process.
private static func probe(_ dotted: String) -> ProbeResult {
guard let environment = try? JavaVirtualMachine.shared().environment() else {
return .undetermined
}

if let found = environment.interface.FindClass(environment, dotted.replacing(".", with: "/")) {
environment.interface.DeleteLocalRef(environment, found)
return .found
}
environment.interface.ExceptionClear(environment)

guard let classLoader = JNI.shared?.applicationClassLoader else {
return .undetermined
}

do {
return try classLoader.loadClass(dotted) != nil ? .found : .notFound
} catch {
return .notFound
}
}
}
#endif

extension AndroidSupport {
/// Performs any known name conversions for types that are desugared by Android core library
/// desugaring, e.g. `java.util.Optional` -> `j$.util.Optional`.
///
/// - Parameter fullClassName: A dotted Java class name, e.g. `java.util.Optional`.
public static func androidDesugarClassNameConversion(
for fullClassName: String
) -> String {
#if os(Android) && AndroidCoreLibraryDesugaring
switch fullClassName {
case "java.util.Optional":
return "j$.util.Optional"
return resolve(dotted: fullClassName)
#else
return fullClassName
#endif
}

/// Same as ``androidDesugarClassNameConversion(for:)``, but for a slashed (JNI-style) class name,
/// e.g. `java/util/Optional`.
public static func androidDesugarClassNameConversionWithSlashes(
for slashedName: String
) -> String {
#if os(Android) && AndroidCoreLibraryDesugaring
let dotted = slashedName.replacing("/", with: ".")
let resolved = resolve(dotted: dotted)
return resolved.replacing(".", with: "/")
#else
return slashedName
#endif
}

default:
break
}
/// Rewrites every class name embedded in a JNI method/field descriptor according to Android core
/// library desugaring, e.g. `"()Ljava/util/Optional;"` -> `"()Lj$/util/Optional;"`.
public static func androidDesugarMethodSignatureConversion(
for signature: String
) -> String {
#if os(Android) && AndroidCoreLibraryDesugaring
return _rewriteDescriptor(signature, mapping: androidDesugarClassNameConversionWithSlashes(for:))
#else
return signature
#endif
return fullClassName
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ For example, if your library's package is `org.swift.exampleapp`, add the follow
### Android Core Library Desugaring

If you are using [Core Library Desugaring](https://developer.android.com/studio/write/java8-support) in your
Android project, you must enable the `AndroidCoreLibraryDesugaring` trait to ensure that the SwiftJava wrappers
use the desugared class names:
Android project, enable the `AndroidCoreLibraryDesugaring` trait so that SwiftJava can find classes that
desugaring relocates to a `j$` package (for example, `java.util.Optional` becomes `j$.util.Optional`):

```swift
let package = Package(
Expand All @@ -42,6 +42,11 @@ let package = Package(
)
```

Enabling the trait does **not** bake a fixed `java.*` -> `j$.*` mapping into the binary. Instead it enables a
runtime probe: the first time SwiftJava needs to resolve a desugarable class, it checks -- once per class name,
cached for the life of the process -- whether the `j$` name is actually loadable in this process, and falls back
to the original `java.*` name otherwise.

### Android SDK Availability

When wrapping the Android SDK (`android.jar`) you can provide the optional `--android-api-version-file` option to `swift-java wrap-java`.
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftJavaMacros/JavaClassMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ extension JavaClassMacro: MemberMacro {
"""
/// The full Java class name for this Swift type.
\(raw: classNameAccessSpecifier) \(raw: fullJavaClassNameMemberModifiers) var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "\(raw: className)")
#else
"\(raw: className)"
Expand Down
28 changes: 17 additions & 11 deletions Sources/SwiftJavaRuntimeSupport/_JNIMethodIDCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ public final class _JNIMethodIDCache: Sendable {
public init(className: String, methods: [Method] = [], fields: [Field] = []) {
let environment = try! JavaVirtualMachine.shared().environment()

// Android core library desugaring relocates some `java.*` classes to `j$.*` in the consuming
// app; this is a no-op everywhere else. See `AndroidSupport` for details.
let resolvedClassName = AndroidSupport.androidDesugarClassNameConversionWithSlashes(for: className)

let clazz: jobject
if let jniClass = environment.interface.FindClass(environment, className) {
if let jniClass = environment.interface.FindClass(environment, resolvedClassName) {
clazz = environment.interface.NewGlobalRef(environment, jniClass)!
environment.interface.DeleteLocalRef(environment, jniClass)
self.javaObjectHolder = nil
Expand All @@ -75,10 +79,10 @@ public final class _JNIMethodIDCache: Sendable {
}
guard
let javaClass = try? jni.applicationClassLoader?.loadClass(
className.replacing("/", with: ".")
resolvedClassName.replacing("/", with: ".")
)
else {
fatalError("Class \(className) could not be found!")
fatalError("Class \(resolvedClassName) could not be found!")
}

clazz = javaClass.javaThis
Expand All @@ -87,36 +91,38 @@ public final class _JNIMethodIDCache: Sendable {

self._class = clazz
self.methods = methods.reduce(into: [:]) { (result, method) in
let signature = AndroidSupport.androidDesugarMethodSignatureConversion(for: method.signature)
if method.isStatic {
if let methodID = environment.interface.GetStaticMethodID(environment, clazz, method.name, method.signature) {
if let methodID = environment.interface.GetStaticMethodID(environment, clazz, method.name, signature) {
result[method] = methodID
} else {
fatalError(
"Static method \(method.signature) with signature \(method.signature) not found in class \(className)"
"Static method \(method.name) with signature \(signature) not found in class \(resolvedClassName)"
)
}
} else {
if let methodID = environment.interface.GetMethodID(environment, clazz, method.name, method.signature) {
if let methodID = environment.interface.GetMethodID(environment, clazz, method.name, signature) {
result[method] = methodID
} else {
fatalError("Method \(method.signature) with signature \(method.signature) not found in class \(className)")
fatalError("Method \(method.name) with signature \(signature) not found in class \(resolvedClassName)")
}
}
}
self.fields = fields.reduce(into: [:]) { (result, field) in
let signature = AndroidSupport.androidDesugarMethodSignatureConversion(for: field.signature)
if field.isStatic {
if let fieldID = environment.interface.GetStaticFieldID(environment, clazz, field.name, field.signature) {
if let fieldID = environment.interface.GetStaticFieldID(environment, clazz, field.name, signature) {
result[field] = fieldID
} else {
fatalError(
"Static field \(field.signature) with signature \(field.signature) not found in class \(className)"
"Static field \(field.name) with signature \(signature) not found in class \(resolvedClassName)"
)
}
} else {
if let fieldID = environment.interface.GetFieldID(environment, clazz, field.name, field.signature) {
if let fieldID = environment.interface.GetFieldID(environment, clazz, field.name, signature) {
result[field] = fieldID
} else {
fatalError("field \(field.signature) with signature \(field.signature) not found in class \(className)")
fatalError("field \(field.name) with signature \(signature) not found in class \(resolvedClassName)")
}
}
}
Expand Down
14 changes: 7 additions & 7 deletions Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class JavaKitMacroTests: XCTestCase {

/// The full Java class name for this Swift type.
open override class var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "org.swift.example.HelloWorld")
#else
"org.swift.example.HelloWorld"
Expand Down Expand Up @@ -197,7 +197,7 @@ class JavaKitMacroTests: XCTestCase {

/// The full Java class name for this Swift type.
public static var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "org.swift.example.HelloWorld")
#else
"org.swift.example.HelloWorld"
Expand Down Expand Up @@ -306,7 +306,7 @@ class JavaKitMacroTests: XCTestCase {

/// The full Java class name for this Swift type.
open override class var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "org.swift.example.HelloWorld")
#else
"org.swift.example.HelloWorld"
Expand Down Expand Up @@ -364,7 +364,7 @@ class JavaKitMacroTests: XCTestCase {

/// The full Java class name for this Swift type.
open class var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "java.lang.Object")
#else
"java.lang.Object"
Expand Down Expand Up @@ -418,7 +418,7 @@ class JavaKitMacroTests: XCTestCase {

/// The full Java class name for this Swift type.
open override class var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "java.lang.Optional")
#else
"java.lang.Optional"
Expand Down Expand Up @@ -537,7 +537,7 @@ class JavaKitMacroTests: XCTestCase {

/// The full Java class name for this Swift type.
open override class var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "com.example.Point")
#else
"com.example.Point"
Expand Down Expand Up @@ -587,7 +587,7 @@ class JavaKitMacroTests: XCTestCase {

/// The full Java class name for this Swift type.
open override class var fullJavaClassName: String {
#if os(Android) && AndroidCoreLibraryDesugaring
#if os(Android)
AndroidSupport.androidDesugarClassNameConversion(for: "java.util.ArrayList")
#else
"java.util.ArrayList"
Expand Down
54 changes: 54 additions & 0 deletions Tests/SwiftJavaTests/AndroidSupportTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//===----------------------------------------------------------------------===//
//
// 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
//
//===----------------------------------------------------------------------===//

@_spi(Testing) import SwiftJava
import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43

class AndroidSupportTests: XCTestCase {
func testDesugaredName() throws {
XCTAssertEqual(AndroidSupport._desugaredName(forDotted: "java.util.Optional"), "j$.util.Optional")
XCTAssertEqual(AndroidSupport._desugaredName(forDotted: "java.util.OptionalInt"), "j$.util.OptionalInt")
XCTAssertEqual(AndroidSupport._desugaredName(forDotted: "java.util.OptionalLong"), "j$.util.OptionalLong")
XCTAssertEqual(AndroidSupport._desugaredName(forDotted: "java.util.OptionalDouble"), "j$.util.OptionalDouble")

XCTAssertNil(AndroidSupport._desugaredName(forDotted: "java.lang.String"))
XCTAssertNil(AndroidSupport._desugaredName(forDotted: "java.util.List"))
XCTAssertNil(AndroidSupport._desugaredName(forDotted: "org.example.Foo"))
}

func testRewriteDescriptor() throws {
// `androidDesugarClassNameConversionWithSlashes` is identity on macOS/Linux, so a test-local
// mapping is injected to exercise the descriptor-scanning logic itself.
let mapping: (String) -> String = { className in
className == "java/util/Optional" ? "j$/util/Optional" : className
}

XCTAssertEqual(
AndroidSupport._rewriteDescriptor("()Ljava/util/Optional;", mapping: mapping),
"()Lj$/util/Optional;"
)
XCTAssertEqual(
AndroidSupport._rewriteDescriptor("(Ljava/lang/Object;)Ljava/util/Optional;", mapping: mapping),
"(Ljava/lang/Object;)Lj$/util/Optional;"
)
XCTAssertEqual(
AndroidSupport._rewriteDescriptor("([Ljava/util/Optional;IJ)V", mapping: mapping),
"([Lj$/util/Optional;IJ)V"
)
XCTAssertEqual(
AndroidSupport._rewriteDescriptor("()Z", mapping: mapping),
"()Z"
)
}
}
Loading