diff --git a/Sources/SwiftJava/AndroidSupport.swift b/Sources/SwiftJava/AndroidSupport.swift index a22933416..26e1c69ab 100644 --- a/Sources/SwiftJava/AndroidSupport.swift +++ b/Sources/SwiftJava/AndroidSupport.swift @@ -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 = [ + "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).. 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 } } diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md b/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md index 417ede71f..430700083 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md @@ -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( @@ -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`. diff --git a/Sources/SwiftJavaMacros/JavaClassMacro.swift b/Sources/SwiftJavaMacros/JavaClassMacro.swift index 6797f61de..d18eb7a19 100644 --- a/Sources/SwiftJavaMacros/JavaClassMacro.swift +++ b/Sources/SwiftJavaMacros/JavaClassMacro.swift @@ -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)" diff --git a/Sources/SwiftJavaRuntimeSupport/_JNIMethodIDCache.swift b/Sources/SwiftJavaRuntimeSupport/_JNIMethodIDCache.swift index 87eb661a7..01317538a 100644 --- a/Sources/SwiftJavaRuntimeSupport/_JNIMethodIDCache.swift +++ b/Sources/SwiftJavaRuntimeSupport/_JNIMethodIDCache.swift @@ -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 @@ -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 @@ -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)") } } } diff --git a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift index da7921182..20103a60b 100644 --- a/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift +++ b/Tests/SwiftJavaMacrosTests/JavaClassMacroTests.swift @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" diff --git a/Tests/SwiftJavaTests/AndroidSupportTests.swift b/Tests/SwiftJavaTests/AndroidSupportTests.swift new file mode 100644 index 000000000..8a3c93aa3 --- /dev/null +++ b/Tests/SwiftJavaTests/AndroidSupportTests.swift @@ -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" + ) + } +}