From f9f5add7aaccf55cc6fd5baac88e3cefcfaf1c2f Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Fri, 6 Mar 2026 12:31:49 +0900 Subject: [PATCH 1/2] Add --android-api-version-file support for availability We parse the xml file and derive availability annotations for Android using it. Turns out the @RequiresApi annotations are a lie and are not present in classes in android.jar, even though it appears as if they are when browsing docs etc; instead, we had to rely on the version xml which is included in the android SDK. --- Package.swift | 1 + .../Commands/WrapJavaCommand.swift | 11 + .../SwiftJavaToolLib/AndroidAPILevel.swift | 9 + .../AndroidAPIVersions.swift | 95 +++++ .../AndroidAPIVersionsParser.swift | 180 +++++++++ .../JavaRuntimeInvisibleAnnotations.swift | 8 +- .../JVMMethodDescriptor.swift | 46 +++ .../JavaClassTranslator.swift | 136 +++++-- Sources/SwiftJavaToolLib/JavaTranslator.swift | 5 + Sources/SwiftJavaToolLib/SwiftVersion.swift | 23 ++ .../AndroidAPIVersionsParserTests.swift | 244 ++++++++++++ .../CompileJavaTool.swift | 14 +- .../CompileJavaWrapTools.swift | 5 +- .../AndroidAPIVersionsWrapJavaTests.swift | 358 ++++++++++++++++++ 14 files changed, 1098 insertions(+), 37 deletions(-) create mode 100644 Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersions.swift create mode 100644 Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersionsParser.swift create mode 100644 Sources/SwiftJavaToolLib/JVMMethodDescriptor.swift create mode 100644 Sources/SwiftJavaToolLib/SwiftVersion.swift create mode 100644 Tests/SwiftJavaToolLibTests/AndroidAPIVersionsParserTests.swift create mode 100644 Tests/SwiftJavaToolLibTests/WrapJavaTests/AndroidAPIVersionsWrapJavaTests.swift diff --git a/Package.swift b/Package.swift index e6de9227..8513da5e 100644 --- a/Package.swift +++ b/Package.swift @@ -389,6 +389,7 @@ let package = Package( dependencies: [ "SwiftJava" ], + exclude: ["swift-java.config"], swiftSettings: [ .swiftLanguageMode(.v5), .unsafeFlags(["-I\(javaIncludePath)", "-I\(javaPlatformIncludePath)"], .when(platforms: [.macOS, .linux, .windows])), diff --git a/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift b/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift index 5cd1bd65..8d544d7f 100644 --- a/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift +++ b/Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift @@ -61,6 +61,9 @@ extension SwiftJava { @Option(help: "If specified, a single Swift file will be generated containing all the generated code") var singleSwiftFileOutput: String? + + @Option(name: .customLong("android-api-version-file"), help: "Path to Android api-versions.xml for generating @available attributes based on API level data") + var androidAPIVersionFile: String? } } @@ -141,6 +144,14 @@ extension SwiftJava.WrapJavaCommand { // Swift-native implementations. translator.swiftNativeImplementations = Set(swiftNativeImplementation) + // Load Android API version data if provided. + if let androidAPIVersionFile { + let url = URL(fileURLWithPath: androidAPIVersionFile) + let apiVersions = try AndroidAPIVersionsParser.parse(contentsOf: url, log: Self.log) + translator.androidAPIVersions = apiVersions + log.info("Loaded Android API versions: \(apiVersions.stats())") + } + // Note all of the dependent configurations. for (swiftModuleName, dependentConfig) in dependentConfigs { translator.addConfiguration( diff --git a/Sources/SwiftJavaToolLib/AndroidAPILevel.swift b/Sources/SwiftJavaToolLib/AndroidAPILevel.swift index 2479ea2a..9b3d6c03 100644 --- a/Sources/SwiftJavaToolLib/AndroidAPILevel.swift +++ b/Sources/SwiftJavaToolLib/AndroidAPILevel.swift @@ -134,4 +134,13 @@ public enum AndroidAPILevel: Int { case .CUR_DEVELOPMENT: "CUR_DEVELOPMENT" } } + + /// Create from an optional string (e.g. an XML attribute value). + /// Returns `nil` if the string is `nil`, not a valid integer, or not a known API level. + public init?(_ string: String?) { + guard let string, let raw = Int(string) else { + return nil + } + self.init(rawValue: raw) + } } diff --git a/Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersions.swift b/Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersions.swift new file mode 100644 index 00000000..d1e89270 --- /dev/null +++ b/Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersions.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Fully-qualified Java class name in dot format, e.g. `com.example.MyClass`. +package typealias FullyQualifiedClassName = String + +/// JVM method descriptor combining method name with parameter and return type descriptors, +/// e.g. `getDisplayId()I` or `(Ljava/lang/String;)V`. +package typealias JVMMethodDescriptor = String + +/// Java field name, e.g. `"ACCEPT_HANDOVER"`. +package typealias FieldName = String + +/// Version info for a single API element (class, method, or field) +/// as recorded in the Android SDK's `api-versions.xml`. +package struct AndroidAPIAvailability { + /// The API level at which this element was introduced. + package var since: AndroidAPILevel? + /// The API level at which this element was removed. + package var removed: AndroidAPILevel? + /// The API level at which this element was deprecated. + package var deprecated: AndroidAPILevel? + + package init(since: AndroidAPILevel? = nil, removed: AndroidAPILevel? = nil, deprecated: AndroidAPILevel? = nil) { + self.since = since + self.removed = removed + self.deprecated = deprecated + } +} + +/// Stores the parsed `api-versions.xml` data and provides query methods +/// for looking up version information by class, method, or field. +/// +/// Class names are stored internally in dot format (e.g. `"android.Manifest$permission"`). +/// Query methods accept either slash or dot format and convert automatically. +package struct AndroidAPIVersions { + /// class name -> version info + var classVersions: [FullyQualifiedClassName: AndroidAPIAvailability] = [:] + /// class name -> (method descriptor -> version info) + var methodVersions: [FullyQualifiedClassName: [JVMMethodDescriptor: AndroidAPIAvailability]] = [:] + /// class name -> (field name -> version info) + var fieldVersions: [FullyQualifiedClassName: [FieldName: AndroidAPIAvailability]] = [:] + + package init() {} + + /// Query version info for a class. + package func versionInfo(forClass className: FullyQualifiedClassName) -> AndroidAPIAvailability? { + classVersions[Self.normalizeClassName(className)] + } + + /// Query version info for a method within a class. + package func versionInfo(forClass className: FullyQualifiedClassName, methodDescriptor: JVMMethodDescriptor) -> AndroidAPIAvailability? { + methodVersions[Self.normalizeClassName(className)]?[methodDescriptor] + } + + /// Query version info for a field within a class. + package func versionInfo(forClass className: FullyQualifiedClassName, fieldName: FieldName) -> AndroidAPIAvailability? { + fieldVersions[Self.normalizeClassName(className)]?[fieldName] + } + + /// Statistics about the parsed data. + package func stats() -> Stats { + Stats( + classCount: classVersions.count, + methodCount: methodVersions.values.reduce(0) { $0 + $1.count }, + fieldCount: fieldVersions.values.reduce(0) { $0 + $1.count } + ) + } + + package struct Stats: CustomStringConvertible { + package var classCount: Int + package var methodCount: Int + package var fieldCount: Int + + public var description: String { + "\(classCount) classes, \(methodCount) methods, \(fieldCount) fields" + } + } + + /// Normalize a class name to dot format, converting slashes if needed. + static func normalizeClassName(_ name: String) -> FullyQualifiedClassName { + name.replacing("/", with: ".") + } +} diff --git a/Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersionsParser.swift b/Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersionsParser.swift new file mode 100644 index 00000000..8b633773 --- /dev/null +++ b/Sources/SwiftJavaToolLib/AndroidAPIVersionSupport/AndroidAPIVersionsParser.swift @@ -0,0 +1,180 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import Logging + +#if canImport(FoundationXML) +import FoundationXML +#endif + +/// Parses an Android `api-versions.xml` file (format version 3) into an ``AndroidAPIVersions``. + +public final class AndroidAPIVersionsParser: _AndroidAPIVersionsParserBase, XMLParserDelegate { + private var result = AndroidAPIVersions() + private var currentClassName: String? + private var currentClassSince: AndroidAPILevel? + private var parseError: Error? + + private let log: Logger + + private init(log: Logger) { + self.log = log + super.init() + } + + /// Parse an `api-versions.xml` file from the given URL. + package static func parse(contentsOf url: URL, log: Logger = .noop) throws -> AndroidAPIVersions { + let data = try Data(contentsOf: url) + return try parse(data: data, log: log) + } + + /// Parse `api-versions.xml` from in-memory data. + package static func parse(data: Data, log: Logger = .noop) throws -> AndroidAPIVersions { + let handler = AndroidAPIVersionsParser(log: log) + let parser = XMLParser(data: data) + parser.delegate = handler + guard parser.parse() else { + if let error = handler.parseError { + throw error + } + throw parser.parserError ?? AndroidAPIVersionsParserError.unknownParseError + } + if let error = handler.parseError { + throw error + } + return handler.result + } + + /// Parse `api-versions.xml` from a string. + package static func parse(string: String, log: Logger = .noop) throws -> AndroidAPIVersions { + let data = Data(string.utf8) + return try parse(data: data, log: log) + } + + // ===== ------------------------------------------------------------------------ + // MARK: - XMLParserDelegate + + public func parser( + _ parser: XMLParser, + didStartElement elementName: String, + namespaceURI: String?, + qualifiedName: String?, + attributes attrs: [String: String] + ) { + switch elementName { + case "api": parseAPI(attrs: attrs) + case "class": parseClass(attrs: attrs) + case "method": parseMethod(attrs: attrs) + case "field": parseField(attrs: attrs) + default: break // ignore , , , etc. + } + } + + private func parseAPI(attrs: [String: String]) { + if let versionStr = attrs["version"], versionStr != "3" { + log.warning("api-versions.xml has version '\(versionStr)', expected '3'. Parsing may be incomplete.") + } + } + + private func parseClass(attrs: [String: String]) { + guard let name = attrs["name"] else { return } + let dotName = name.replacing("/", with: ".") + currentClassName = dotName + let since = AndroidAPILevel(attrs["since"]) + currentClassSince = since + let info = AndroidAPIAvailability( + since: since, + removed: AndroidAPILevel(attrs["removed"]), + deprecated: AndroidAPILevel(attrs["deprecated"]) + ) + result.classVersions[dotName] = info + } + + private func parseMethod(attrs: [String: String]) { + guard let className = currentClassName, + let name = attrs["name"] + else { return } + let info = AndroidAPIAvailability( + since: AndroidAPILevel(attrs["since"]) ?? currentClassSince, + removed: AndroidAPILevel(attrs["removed"]), + deprecated: AndroidAPILevel(attrs["deprecated"]) + ) + result.methodVersions[className, default: [:]][name] = info + } + + private func parseField(attrs: [String: String]) { + guard let className = currentClassName, + let name = attrs["name"] + else { return } + let info = AndroidAPIAvailability( + since: AndroidAPILevel(attrs["since"]) ?? currentClassSince, + removed: AndroidAPILevel(attrs["removed"]), + deprecated: AndroidAPILevel(attrs["deprecated"]) + ) + result.fieldVersions[className, default: [:]][name] = info + } + + public func parser( + _ parser: XMLParser, + didEndElement elementName: String, + namespaceURI: String?, + qualifiedName: String? + ) { + if elementName == "class" { + currentClassName = nil + currentClassSince = nil + } + } + + public func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) { + self.parseError = parseError + } +} + +// ===== ------------------------------------------------------------------------ +// MARK: - Errors + +public enum AndroidAPIVersionsParserError: Error, CustomStringConvertible { + case unknownParseError + + public var description: String { + switch self { + case .unknownParseError: + "Unknown error parsing api-versions.xml" + } + } +} + +// ===== ------------------------------------------------------------------------ +// MARK: - Platform base class + +/// On Apple platforms `XMLParserDelegate` is an `@objc` protocol, so the class +/// inherits from `NSObject`. On Linux (swift-corelibs-foundation) the protocol +/// is a plain Swift protocol and no base class is needed. +#if canImport(ObjectiveC) +public class _AndroidAPIVersionsParserBase: NSObject {} +#else +public class _AndroidAPIVersionsParserBase {} +#endif + +// ===== ------------------------------------------------------------------------ +// MARK: - Logger extensions + +extension Logger { + /// A logger that silently discards all log messages. + public static var noop: Logger { + Logger(label: "noop", factory: { _ in SwiftLogNoOpLogHandler() }) + } +} diff --git a/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift index 778a7aba..fdcdb889 100644 --- a/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift +++ b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift @@ -28,7 +28,7 @@ struct JavaRuntimeInvisibleAnnotations { /// Returns annotations for a Java method, matched by name and exact descriptor. func annotationsFor(method javaMethod: Method) -> [JavaRuntimeInvisibleAnnotation] { - let descriptor = Self.jvmDescriptor( + let descriptor = jvmDescriptor( parameterTypes: javaMethod.getParameterTypes(), returnType: javaMethod.getReturnType() ) @@ -38,7 +38,7 @@ struct JavaRuntimeInvisibleAnnotations { /// Returns annotations for a Java constructor, matched by exact descriptor. func annotationsFor(constructor: some Executable) -> [JavaRuntimeInvisibleAnnotation] { - let descriptor = Self.jvmDescriptor( + let descriptor = jvmDescriptor( parameterTypes: constructor.getParameterTypes(), returnType: nil // constructors return void ) @@ -51,9 +51,9 @@ struct JavaRuntimeInvisibleAnnotations { fieldAnnotations[name] ?? [] } - /// Build a JVM method descriptor from parameter types and return type. + /// Build a JVM type descriptor from parameter types and return type. /// E.g. `(Ljava/lang/String;)V` for `void doSomething(String)`. - private static func jvmDescriptor( + private func jvmDescriptor( parameterTypes: [JavaClass?], returnType: JavaClass? ) -> String { diff --git a/Sources/SwiftJavaToolLib/JVMMethodDescriptor.swift b/Sources/SwiftJavaToolLib/JVMMethodDescriptor.swift new file mode 100644 index 00000000..aae5bf76 --- /dev/null +++ b/Sources/SwiftJavaToolLib/JVMMethodDescriptor.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 JavaLangReflect +import SwiftJava + +/// Build a JVM method descriptor from a method name, parameter types, and return type. +/// E.g. `"getDisplayId()I"` or `"(Landroid/content/Context;)V"`. +func jvmMethodDescriptor( + name: String, + parameterTypes: [JavaClass?], + returnType: JavaClass? +) -> String { + let params = parameterTypes.map { $0?.descriptorString() ?? "Ljava/lang/Object;" }.joined() + let ret = returnType?.descriptorString() ?? "V" + return "\(name)(\(params))\(ret)" +} + +/// Build a JVM method descriptor for a reflected method. +func jvmMethodDescriptor(_ method: Method) -> String { + jvmMethodDescriptor( + name: method.getName(), + parameterTypes: method.getParameterTypes(), + returnType: method.getReturnType() + ) +} + +/// Build a JVM method descriptor for a reflected constructor. +func jvmMethodDescriptor(_ constructor: some Executable) -> String { + jvmMethodDescriptor( + name: "", + parameterTypes: constructor.getParameterTypes(), + returnType: nil + ) +} diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index f8c6b906..463e6e5b 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -208,7 +208,7 @@ struct JavaClassTranslator { let classCount = self.runtimeInvisibleAnnotations.classAnnotations.count let methodCount = self.runtimeInvisibleAnnotations.methodAnnotations.count let fieldCount = self.runtimeInvisibleAnnotations.fieldAnnotations.count - translator.log.info("Parsed runtime invisible annotations for '\(fullName)': \(classCount) class, \(methodCount) method, \(fieldCount) field") + translator.log.debug("Parsed runtime invisible annotations for '\(fullName)': \(classCount) class, \(methodCount) method, \(fieldCount) field") } catch { translator.log.warning("Failed to read .class bytes for '\(fullName)': \(error)") self.runtimeInvisibleAnnotations = JavaRuntimeInvisibleAnnotations() @@ -474,7 +474,8 @@ extension JavaClassTranslator { let introducer = translateAsClass ? "open class" : "public struct" let classAvailableAttributes = swiftAvailableAttributes( from: annotations, - runtimeInvisibleAnnotations: self.runtimeInvisibleAnnotations.classAnnotations + runtimeInvisibleAnnotations: self.runtimeInvisibleAnnotations.classAnnotations, + javaClass: javaClass ) var classDecl: DeclSyntax = """ @@ -630,10 +631,9 @@ extension JavaClassTranslator { /// The attribute text, e.g. `@available(*, deprecated)`. var value: String - /// The minimum Swift compiler version required to compile this attribute, - /// e.g. `(6, 3)` for `@available(Android ...)`. When non-nil the attribute - /// is wrapped in a `#if compiler(>=…)` block during rendering. - var minimumCompilerVersion: (Int, Int)? = nil + /// The minimum Swift compiler version required to compile this attribute. + /// When non-nil the attribute is wrapped in a `#if compiler(>=…)` block during rendering. + var minimumCompilerVersion: SwiftVersion? = nil } struct SwiftAvailableAttributes { @@ -645,8 +645,11 @@ extension JavaClassTranslator { } var lines: [String] = [] for attr in attributes { - if let (major, minor) = attr.minimumCompilerVersion { - lines.append("#if compiler(>=\(major).\(minor))") + if let version = attr.minimumCompilerVersion { + let versionString = + version.patch.map { "\(version.major).\(version.minor).\($0)" } + ?? "\(version.major).\(version.minor)" + lines.append("#if compiler(>=\(versionString))") lines.append(attr.value) lines.append("#endif") } else { @@ -657,47 +660,111 @@ extension JavaClassTranslator { } } + private func apiLevelComment(_ level: Int32) -> String { + AndroidAPILevel(rawValue: Int(level)).map { " /* \($0.name) */" } ?? "" + } + + private func availabilityFromBinRequiresApi(_ binAnnotation: JavaRuntimeInvisibleAnnotation) -> SwiftAttribute? { + let apiLevel: Int32? = + if let api = binAnnotation.elements["api"], api > 0 { + api + } else if let value = binAnnotation.elements["value"], value > 0 { + value + } else { + nil + } + + guard let apiLevel else { return nil } + return SwiftAttribute( + value: "@available(Android \(apiLevel)\(apiLevelComment(apiLevel)), *)", + minimumCompilerVersion: .androidPlatformAvailability + ) + } + /// Build Swift `@available` attributes from Java annotations on a reflective element. private func swiftAvailableAttributes( from runtimeAnnotations: [Annotation], - runtimeInvisibleAnnotations: [JavaRuntimeInvisibleAnnotation] = [] + runtimeInvisibleAnnotations: [JavaRuntimeInvisibleAnnotation] = [], + javaClass: JavaClass? = nil, + javaMethod: Method? = nil, + javaConstructor: Executable? = nil, + javaFieldName: String? = nil ) -> SwiftAvailableAttributes { var result = SwiftAvailableAttributes() - func apiLevelComment(_ level: Int32) -> String { - AndroidAPILevel(rawValue: Int(level)).map { " /* \($0.name) */" } ?? "" - } - for annotation in runtimeAnnotations { guard let annotationClass = annotation.annotationType() else { continue } if annotationClass.isKnown(.javaLangDeprecated) { - result.attributes.append(SwiftAttribute(value: "@available(*, deprecated)")) + result.attributes += [SwiftAttribute(value: "@available(*, deprecated)")] } } - // Look for any annotations stored in classfiles, e.g. the Android @RequiresApi + // Look for any annotations stored in classfiles, e.g. the Android @ for binAnnotation in runtimeInvisibleAnnotations { let fqn = binAnnotation.fullyQualifiedName + + // Handle Android's RequiresApi; though they don't exist in android.jar (!) if fqn == KnownJavaAnnotation.androidxRequiresApi.rawValue || fqn == KnownJavaAnnotation.androidSupportRequiresApi.rawValue { - let apiLevel: Int32? = - if let api = binAnnotation.elements["api"], api > 1 { - api - } else if let value = binAnnotation.elements["value"], value > 1 { - value - } else { - nil - } + if let attr = availabilityFromBinRequiresApi(binAnnotation) { + result.attributes += [attr] + } + } + } - if let apiLevel { - result.attributes.append( + // For android, the RequiresApi actually are synthetic and stored in api-versions.xml, + // so consult that if available + if let apiVersions = translator.androidAPIVersions, let javaClass { + let className = javaClass.getName() + let versionInfo: AndroidAPIAvailability? = + if let javaMethod { + apiVersions.versionInfo(forClass: className, methodDescriptor: jvmMethodDescriptor(javaMethod)) + } else if let javaConstructor { + apiVersions.versionInfo(forClass: className, methodDescriptor: jvmMethodDescriptor(javaConstructor)) + } else if let fieldName = javaFieldName { + apiVersions.versionInfo(forClass: className, fieldName: fieldName) + } else { + apiVersions.versionInfo(forClass: className) + } + + if let info = versionInfo { + let alreadyHasAndroidAvailable = result.attributes.contains { + $0.value.contains("@available(Android") + } + + // Only add since from api-versions.xml if @RequiresApi didn't already provide one. + if !alreadyHasAndroidAvailable, let since = info.since, since.rawValue > 0 { + result.attributes += [ SwiftAttribute( - value: "@available(Android \(apiLevel)\(apiLevelComment(apiLevel)), *)", - minimumCompilerVersion: (6, 3) + value: "@available(Android \(since.rawValue) /* \(since.name) */, *)", + minimumCompilerVersion: .androidPlatformAvailability ) - ) + ] + } + + let alreadyHasDeprecated = result.attributes.contains { + $0.value.contains("deprecated") + } + + // Handle deprecated APIs; also emit deprecated for removed APIs if not already deprecated. + if !alreadyHasDeprecated, let deprecated = info.deprecated { + result.attributes += [ + SwiftAttribute( + value: "@available(Android, deprecated: \(deprecated.rawValue), message: \"Deprecated in Android API \(deprecated.rawValue) /* \(deprecated.name) */\")", + minimumCompilerVersion: .androidPlatformAvailability + ) + ] + } else if !alreadyHasDeprecated, let removed = info.removed { + // Swift's '@available(Android, unavailable, ...' does not accept a version so we don't use it, + // since it may prevent calling an API that's actually still there in some Android version we're targeting. + result.attributes += [ + SwiftAttribute( + value: "@available(Android, deprecated: \(removed.rawValue), message: \"Removed in Android API \(removed.rawValue) /* \(removed.name) */\")", + minimumCompilerVersion: .androidPlatformAvailability + ) + ] } } } @@ -705,6 +772,7 @@ extension JavaClassTranslator { return result } + func renderAnnotationExtensions() -> [DeclSyntax] { var extensions: [DeclSyntax] = [] @@ -744,7 +812,9 @@ extension JavaClassTranslator { let invisibleCtorAnnotations = runtimeInvisibleAnnotations.annotationsFor(constructor: javaConstructor) let availableAttributes = swiftAvailableAttributes( from: constructorAnnotations, - runtimeInvisibleAnnotations: invisibleCtorAnnotations + runtimeInvisibleAnnotations: invisibleCtorAnnotations, + javaClass: javaClass, + javaConstructor: javaConstructor ) // FIXME: handle generics in constructors @@ -873,7 +943,9 @@ extension JavaClassTranslator { let invisibleMethodAnnotations = runtimeInvisibleAnnotations.annotationsFor(method: javaMethod) let availableAttributes = swiftAvailableAttributes( from: methodAnnotations, - runtimeInvisibleAnnotations: invisibleMethodAnnotations + runtimeInvisibleAnnotations: invisibleMethodAnnotations, + javaClass: javaClass, + javaMethod: javaMethod ) // Compute the parameters for '@...JavaMethod(...)' @@ -975,7 +1047,9 @@ extension JavaClassTranslator { let invisibleFieldAnnotations = runtimeInvisibleAnnotations.annotationsFor(field: javaField.getName()) let availableAttributes = swiftAvailableAttributes( from: fieldAnnotations, - runtimeInvisibleAnnotations: invisibleFieldAnnotations + runtimeInvisibleAnnotations: invisibleFieldAnnotations, + javaClass: javaClass, + javaFieldName: javaField.getName() ) if let optionalType = typeName.optionalWrappedType() { diff --git a/Sources/SwiftJavaToolLib/JavaTranslator.swift b/Sources/SwiftJavaToolLib/JavaTranslator.swift index 200f5b71..400e623d 100644 --- a/Sources/SwiftJavaToolLib/JavaTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaTranslator.swift @@ -66,6 +66,11 @@ package class JavaTranslator { /// methods will be implemented in Swift. package var swiftNativeImplementations: Set = [] + /// Parsed Android `api-versions.xml` data, if available. + /// When set, the translator will emit `@available(Android ...)` attributes + /// based on API-level introduction, deprecation, and removal data. + package var androidAPIVersions: AndroidAPIVersions? + /// The set of nested classes that we should traverse from the given class, /// indexed by the name of the class. /// diff --git a/Sources/SwiftJavaToolLib/SwiftVersion.swift b/Sources/SwiftJavaToolLib/SwiftVersion.swift new file mode 100644 index 00000000..5faceec5 --- /dev/null +++ b/Sources/SwiftJavaToolLib/SwiftVersion.swift @@ -0,0 +1,23 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 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 Swift compiler version with major, minor, and optional patch components. +struct SwiftVersion { + var major: Int + var minor: Int + var patch: Int? + + /// The minimum compiler version required for `@available(Android ...)` platform availability. + static let androidPlatformAvailability = SwiftVersion(major: 6, minor: 3) +} diff --git a/Tests/SwiftJavaToolLibTests/AndroidAPIVersionsParserTests.swift b/Tests/SwiftJavaToolLibTests/AndroidAPIVersionsParserTests.swift new file mode 100644 index 00000000..3f037595 --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/AndroidAPIVersionsParserTests.swift @@ -0,0 +1,244 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import XCTest + +@testable import SwiftJavaToolLib + +final class AndroidAPIVersionsParserTests: XCTestCase { + + // ===== ------------------------------------------------------------------------ + // MARK: - Tests + + func test_parseBasicClass() throws { + let xml = """ + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + let info = versions.versionInfo(forClass: "android/widget/TextView") + XCTAssertNotNil(info) + XCTAssertEqual(info?.since, .BASE) + XCTAssertNil(info?.removed) + XCTAssertNil(info?.deprecated) + } + + func test_parseClassWithDeprecatedAndRemoved() throws { + let xml = """ + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + let info = versions.versionInfo(forClass: "android/app/OldActivity") + XCTAssertNotNil(info) + XCTAssertEqual(info?.since, .CUPCAKE) + XCTAssertEqual(info?.deprecated, .ICE_CREAM_SANDWICH_MR1) + XCTAssertEqual(info?.removed, .P) + } + + func test_parseMethodWithSince() throws { + let xml = """ + + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + + let methodInfo = versions.versionInfo(forClass: "android/view/Display", methodDescriptor: "getDisplayId()I") + XCTAssertNotNil(methodInfo) + XCTAssertEqual(methodInfo?.since, .JELLY_BEAN_MR1) + + // Constructor inherits class since + let ctorInfo = versions.versionInfo(forClass: "android/view/Display", methodDescriptor: "()V") + XCTAssertNotNil(ctorInfo) + XCTAssertEqual(ctorInfo?.since, .BASE) + } + + func test_parseFieldWithDeprecatedAndRemoved() throws { + let xml = """ + + + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + + let f1 = versions.versionInfo(forClass: "android/Manifest$permission", fieldName: "ACCEPT_HANDOVER") + XCTAssertEqual(f1?.since, .P) + XCTAssertNil(f1?.removed) + XCTAssertNil(f1?.deprecated) + + let f2 = versions.versionInfo(forClass: "android/Manifest$permission", fieldName: "ACCESS_MOCK_LOCATION") + XCTAssertEqual(f2?.since, .BASE) // inherited from class + XCTAssertEqual(f2?.removed, .M) + + let f3 = versions.versionInfo(forClass: "android/Manifest$permission", fieldName: "BIND_CARRIER_MESSAGING_SERVICE") + XCTAssertEqual(f3?.since, .LOLLIPOP_MR1) + XCTAssertEqual(f3?.deprecated, .M) + } + + func test_memberInheritsSinceFromClass() throws { + let xml = """ + + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + + // Method without explicit since inherits class since=5 (ECLAIR) + let methodInfo = versions.versionInfo(forClass: "android/app/Activity", methodDescriptor: "onCreate(Landroid/os/Bundle;)V") + XCTAssertEqual(methodInfo?.since, .ECLAIR) + + // Field without explicit since inherits class since=5 (ECLAIR) + let fieldInfo = versions.versionInfo(forClass: "android/app/Activity", fieldName: "RESULT_OK") + XCTAssertEqual(fieldInfo?.since, .ECLAIR) + } + + func test_classNameDotFormatQuery() throws { + let xml = """ + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + + // Query using dot format + let classInfo = versions.versionInfo(forClass: "android.widget.Button") + XCTAssertNotNil(classInfo) + XCTAssertEqual(classInfo?.since, .BASE) + + let fieldInfo = versions.versionInfo(forClass: "android.widget.Button", fieldName: "STYLE") + XCTAssertEqual(fieldInfo?.since, .LOLLIPOP) + } + + func test_classWithNoSince() throws { + let xml = """ + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + + let classInfo = versions.versionInfo(forClass: "android/os/Build") + XCTAssertNotNil(classInfo) + XCTAssertNil(classInfo?.since) // no since attribute + + // Field also has nil since (inherits nil from class) + let fieldInfo = versions.versionInfo(forClass: "android/os/Build", fieldName: "BOARD") + XCTAssertNotNil(fieldInfo) + XCTAssertNil(fieldInfo?.since) + } + + func test_queryNonexistentClassReturnsNil() throws { + let xml = """ + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + + XCTAssertNil(versions.versionInfo(forClass: "android/nonexistent/Class")) + XCTAssertNil(versions.versionInfo(forClass: "android/widget/TextView", methodDescriptor: "noSuchMethod()V")) + XCTAssertNil(versions.versionInfo(forClass: "android/widget/TextView", fieldName: "NO_SUCH_FIELD")) + } + + func test_stats() throws { + let xml = """ + + + + + + + + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + let stats = versions.stats() + XCTAssertEqual(stats.classCount, 2) + XCTAssertEqual(stats.methodCount, 3) // 2 Activity methods + 1 Button constructor + XCTAssertEqual(stats.fieldCount, 3) // 1 Activity field + 2 Button fields + } + + func test_multipleClasses() throws { + let xml = """ + + + + + + + + + + + + """ + let versions = try AndroidAPIVersionsParser.parse(string: xml) + + XCTAssertEqual(versions.versionInfo(forClass: "android/app/Activity")?.since, .BASE) + XCTAssertEqual(versions.versionInfo(forClass: "android/content/Context")?.since, .BASE) + XCTAssertEqual(versions.versionInfo(forClass: "android/os/Build$VERSION")?.since, .DONUT) + XCTAssertEqual(versions.versionInfo(forClass: "android/os/Build$VERSION", fieldName: "SDK_INT")?.since, .DONUT) + } + + func test_parseRealFile() throws { + // Try to parse the real api-versions.xml if it exists on this machine + let possiblePaths = [ + "\(NSHomeDirectory())/Library/Android/sdk/platforms/android-35/data/api-versions.xml", + "\(NSHomeDirectory())/Library/Android/sdk/platforms/android-34/data/api-versions.xml", + ] + guard let path = possiblePaths.first(where: { FileManager.default.fileExists(atPath: $0) }) else { + // Skip test if no Android SDK is installed + throw XCTSkip("No Android SDK api-versions.xml found on this machine") + } + + let url = URL(fileURLWithPath: path) + let versions = try AndroidAPIVersionsParser.parse(contentsOf: url) + let stats = versions.stats() + + // The real file should have a significant amount of data + XCTAssertGreaterThan(stats.classCount, 1000, "Expected many classes in real api-versions.xml") + XCTAssertGreaterThan(stats.methodCount, 10000, "Expected many methods in real api-versions.xml") + XCTAssertGreaterThan(stats.fieldCount, 1000, "Expected many fields in real api-versions.xml") + + // Spot-check some well-known classes + let activityInfo = versions.versionInfo(forClass: "android/app/Activity") + XCTAssertNotNil(activityInfo, "android.app.Activity should exist") + XCTAssertEqual(activityInfo?.since, .BASE) + } +} diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift index 4c9ca102..fff433ba 100644 --- a/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift +++ b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift @@ -72,7 +72,19 @@ struct CompileJavaTool { /// - Parameter sourceText: The Java source code. /// - Returns: The directory containing compiled `.class` files (the classpath root). static func compileJava(_ sourceText: String) async throws -> Foundation.URL { - let sourceFile = try TempFile.create(suffix: "java", sourceText) + // Java requires public class files to be named after the class + let sourceFile: Foundation.URL + if let match = sourceText.range(of: #"public\s+class\s+(\w+)"#, options: .regularExpression) { + let classNameRange = sourceText[match] + let className = classNameRange.split(separator: " ").last.map(String.init) ?? "tmp_\(UUID().uuidString)" + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent("swift-java-src-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + sourceFile = dir.appendingPathComponent("\(className).java") + try sourceText.write(to: sourceFile, atomically: true, encoding: .utf8) + } else { + sourceFile = try TempFile.create(suffix: "java", sourceText) + } let classesDir = FileManager.default.temporaryDirectory .appendingPathComponent("swift-java-testing-\(UUID().uuidString)") diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift index f1dbc4ee..747febd9 100644 --- a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift +++ b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift @@ -19,9 +19,10 @@ import Subprocess @_spi(Testing) import SwiftJava import SwiftJavaConfigurationShared import SwiftJavaShared -import SwiftJavaToolLib import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 +@testable import SwiftJavaToolLib + /// Returns the directory that should be added to the classpath of the JVM to analyze the sources. func compileJava(_ sourceText: String) async throws -> Foundation.URL { try await CompileJavaTool.compileJava(sourceText) @@ -62,6 +63,7 @@ func assertWrapJavaOutput( classNameMappings: [String: String] = [:], classpath: [Foundation.URL], makeJar: Bool = false, + androidAPIVersions: AndroidAPIVersions? = nil, assert assertBody: (JavaTranslator) throws -> Void = { _ in }, expectedChunks: [String], function: String = #function, @@ -85,6 +87,7 @@ func assertWrapJavaOutput( environment: environment, translateAsClass: true ) + translator.androidAPIVersions = androidAPIVersions let classpathJavaURLs: [JavaNet.URL] if makeJar { diff --git a/Tests/SwiftJavaToolLibTests/WrapJavaTests/AndroidAPIVersionsWrapJavaTests.swift b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AndroidAPIVersionsWrapJavaTests.swift new file mode 100644 index 00000000..42837212 --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AndroidAPIVersionsWrapJavaTests.swift @@ -0,0 +1,358 @@ +//===----------------------------------------------------------------------===// +// +// 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 Foundation +import JavaNet +import JavaUtilJar +import Subprocess +@_spi(Testing) import SwiftJava +import SwiftJavaConfigurationShared +import SwiftJavaShared +import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 + +@testable import SwiftJavaToolLib + +/// Tests for `@available` attribute generation from Android `api-versions.xml` data. +final class AndroidAPIVersionsWrapJavaTests: XCTestCase { + + /// Java source for a CLASS-retention `@RequiresApi` annotation, + /// matching real AndroidX behavior. + static let requiresApiAnnotationSource = """ + package androidx.annotation; + import java.lang.annotation.*; + @Retention(RetentionPolicy.CLASS) + @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) + public @interface RequiresApi { + int api() default 1; + int value() default 1; + } + """ + + // ==== ------------------------------------------------ + // MARK: since + + func testWrapJava_androidAPIVersions_sinceOnClass() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public class VersionedClass { + public void doWork() {} + } + """ + ) + + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.VersionedClass"] = AndroidAPIAvailability(since: .LOLLIPOP) + + try assertWrapJavaOutput( + javaClassNames: ["com.example.VersionedClass"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 21 /* Lollipop */, *) + #endif + @JavaClass("com.example.VersionedClass") + open class VersionedClass: JavaObject { + """ + ] + ) + } + + func testWrapJava_androidAPIVersions_sinceOnMethod() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public class MethodVersioned { + public void newMethod() {} + public void oldMethod() {} + } + """ + ) + + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.MethodVersioned"] = AndroidAPIAvailability(since: .BASE) + apiVersions.methodVersions["com.example.MethodVersioned"] = [ + "newMethod()V": AndroidAPIAvailability(since: .TIRAMISU) + ] + + try assertWrapJavaOutput( + javaClassNames: ["com.example.MethodVersioned"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaMethod + open func newMethod() + """, + """ + @JavaMethod + open func oldMethod() + """, + ] + ) + } + + // ==== ------------------------------------------------ + // MARK: deprecated + + func testWrapJava_androidAPIVersions_deprecatedMethod() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public class DeprecatedByVersions { + public void stableMethod() {} + public void deprecatedMethod() {} + } + """ + ) + + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.DeprecatedByVersions"] = AndroidAPIAvailability(since: .BASE) + apiVersions.methodVersions["com.example.DeprecatedByVersions"] = [ + "deprecatedMethod()V": AndroidAPIAvailability(since: .ECLAIR, deprecated: .P) + ] + + try assertWrapJavaOutput( + javaClassNames: ["com.example.DeprecatedByVersions"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 5 /* Eclair */, *) + #endif + #if compiler(>=6.3) + @available(Android, deprecated: 28, message: "Deprecated in Android API 28 /* Pie */") + #endif + @JavaMethod + open func deprecatedMethod() + """, + """ + @JavaMethod + open func stableMethod() + """, + ] + ) + } + + func testWrapJava_androidAPIVersions_deprecatedClass() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public class OldVersionedClass { + public void doWork() {} + } + """ + ) + + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.OldVersionedClass"] = AndroidAPIAvailability( + since: .CUPCAKE, + deprecated: .Q + ) + + try assertWrapJavaOutput( + javaClassNames: ["com.example.OldVersionedClass"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 3 /* Cupcake */, *) + #endif + #if compiler(>=6.3) + @available(Android, deprecated: 29, message: "Deprecated in Android API 29 /* Android 10 */") + #endif + @JavaClass("com.example.OldVersionedClass") + open class OldVersionedClass: JavaObject { + """ + ] + ) + } + + // ==== ------------------------------------------------ + // MARK: removed + + func testWrapJava_androidAPIVersions_removedMethod() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public class RemovedByVersions { + public void removedMethod() {} + public void activeMethod() {} + } + """ + ) + + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.RemovedByVersions"] = AndroidAPIAvailability(since: .BASE) + apiVersions.methodVersions["com.example.RemovedByVersions"] = [ + "removedMethod()V": AndroidAPIAvailability(since: .CUPCAKE, removed: .P) + ] + + try assertWrapJavaOutput( + javaClassNames: ["com.example.RemovedByVersions"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + // Removed APIs emit deprecated instead of unavailable, since Swift's 'unavailable' doesn't accept a version + """ + #if compiler(>=6.3) + @available(Android, deprecated: 28, message: "Removed in Android API 28 /* Pie */") + #endif + @JavaMethod + open func removedMethod() + """, + """ + @JavaMethod + open func activeMethod() + """, + ] + ) + } + + func testWrapJava_androidAPIVersions_removedAndDeprecated() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public class DeprecatedThenRemoved { + public void goneMethod() {} + } + """ + ) + + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.DeprecatedThenRemoved"] = AndroidAPIAvailability(since: .BASE) + apiVersions.methodVersions["com.example.DeprecatedThenRemoved"] = [ + "goneMethod()V": AndroidAPIAvailability(since: .CUPCAKE, removed: .P, deprecated: .ICE_CREAM_SANDWICH_MR1) + ] + + try assertWrapJavaOutput( + javaClassNames: ["com.example.DeprecatedThenRemoved"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + // deprecated is emitted; removed doesn't add a second deprecated since one already exists + """ + #if compiler(>=6.3) + @available(Android, deprecated: 15, message: "Deprecated in Android API 15 /* Ice Cream Sandwich MR1 */") + #endif + @JavaMethod + open func goneMethod() + """ + ] + ) + } + + // ==== ------------------------------------------------ + // MARK: fields + + func testWrapJava_androidAPIVersions_fieldSinceAndDeprecated() async throws { + let classpathURL = try await compileJava( + """ + package com.example; + + public class FieldVersioned { + public static int NEW_FIELD = 1; + public static int OLD_FIELD = 2; + } + """ + ) + + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.FieldVersioned"] = AndroidAPIAvailability(since: .BASE) + apiVersions.fieldVersions["com.example.FieldVersioned"] = [ + "NEW_FIELD": AndroidAPIAvailability(since: .P), + "OLD_FIELD": AndroidAPIAvailability(since: .ECLAIR, deprecated: .M), + ] + + try assertWrapJavaOutput( + javaClassNames: ["com.example.FieldVersioned"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 28 /* Pie */, *) + #endif + @JavaStaticField(isFinal: false) + public var NEW_FIELD: Int32 + """, + """ + #if compiler(>=6.3) + @available(Android 5 /* Eclair */, *) + #endif + #if compiler(>=6.3) + @available(Android, deprecated: 23, message: "Deprecated in Android API 23 /* Marshmallow */") + #endif + @JavaStaticField(isFinal: false) + public var OLD_FIELD: Int32 + """, + ] + ) + } + + // ==== ------------------------------------------------ + // MARK: interaction with @RequiresApi + + func testWrapJava_androidAPIVersions_doesNotOverrideRequiresApi() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/MixedSources.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + + public class MixedSources { + @RequiresApi(api = 30) + public void annotatedMethod() {} + } + """, + ]) + + // api-versions.xml says since=21, but @RequiresApi(30) should win + var apiVersions = AndroidAPIVersions() + apiVersions.classVersions["com.example.MixedSources"] = AndroidAPIAvailability(since: .BASE) + apiVersions.methodVersions["com.example.MixedSources"] = [ + "annotatedMethod()V": AndroidAPIAvailability(since: .LOLLIPOP) + ] + + try assertWrapJavaOutput( + javaClassNames: ["com.example.MixedSources"], + classpath: [classpathURL], + androidAPIVersions: apiVersions, + expectedChunks: [ + // @RequiresApi(30) wins, api-versions since=21 is NOT duplicated + """ + #if compiler(>=6.3) + @available(Android 30 /* Android 11 */, *) + #endif + @JavaMethod + open func annotatedMethod() + """ + ] + ) + } +} From 9d3d6b305206cfb091b292a103cd0e40c58c40af Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Mon, 9 Mar 2026 15:41:40 +0900 Subject: [PATCH 2/2] add docs about --android-api-version-file --- .../Documentation.docc/Android.md | 20 +++++++++++++++ .../ClassParsing/JavaClassFileReader.swift | 2 +- .../JavaRuntimeInvisibleAnnotations.swift | 25 +++---------------- ...dDescriptor.swift => JVMDescriptors.swift} | 2 +- .../JavaClassFileParserTests.swift | 14 +++++------ 5 files changed, 32 insertions(+), 31 deletions(-) rename Sources/SwiftJavaToolLib/{JVMMethodDescriptor.swift => JVMDescriptors.swift} (96%) diff --git a/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md b/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md index 537566b2..68902e87 100644 --- a/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md +++ b/Sources/SwiftJavaDocumentation/Documentation.docc/Android.md @@ -21,4 +21,24 @@ let package = Package( ] ) ``` + +### 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`. + +This file contains availability information for Android APIs, which swift-java will take into account when generating the wrappers. +All APIs will therefore be annotated with their respective availability, expressed using Swift's `@available`: + +```swift +#if compiler(>=6.3) +@available(Android 3 /* Cupcake */, *) +@available(Android, deprecated: 29, message: "Deprecated in Android API 29 /* Android 10 */") +#endif +@JavaClass("com.example.OldVersionedClass") +open class OldVersionedClass: JavaObject { +} ``` + +Annotations are generated both for "since", "deprecated" and "removed" attributes. + +> Note: To use Android platform availability you must use at least Swift 6.3, which introduced the `Android` platform. \ No newline at end of file diff --git a/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift b/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift index 957e5071..a365361a 100644 --- a/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift +++ b/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift @@ -214,7 +214,7 @@ extension JavaClassFileReader { let key: String if includeDescriptor { let descriptor = utf8Constants[descriptorIndex] ?? "" - key = "\(name):\(descriptor)" + key = "\(name)\(descriptor)" } else { key = name } diff --git a/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift index fdcdb889..aefb43d7 100644 --- a/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift +++ b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift @@ -20,7 +20,7 @@ struct JavaRuntimeInvisibleAnnotations { /// Annotations on the class itself. var classAnnotations: [JavaRuntimeInvisibleAnnotation] = [] - /// Annotations keyed by method name + descriptor, e.g. "api30Method:()V" + /// Annotations keyed by method descriptor, e.g. "api30Method()V" var methodAnnotations: [String: [JavaRuntimeInvisibleAnnotation]] = [:] /// Annotations keyed by field name, e.g. "OLD_VALUE" @@ -28,21 +28,13 @@ struct JavaRuntimeInvisibleAnnotations { /// Returns annotations for a Java method, matched by name and exact descriptor. func annotationsFor(method javaMethod: Method) -> [JavaRuntimeInvisibleAnnotation] { - let descriptor = jvmDescriptor( - parameterTypes: javaMethod.getParameterTypes(), - returnType: javaMethod.getReturnType() - ) - let key = "\(javaMethod.getName()):\(descriptor)" + let key = jvmMethodDescriptor(javaMethod) return methodAnnotations[key] ?? [] } /// Returns annotations for a Java constructor, matched by exact descriptor. func annotationsFor(constructor: some Executable) -> [JavaRuntimeInvisibleAnnotation] { - let descriptor = jvmDescriptor( - parameterTypes: constructor.getParameterTypes(), - returnType: nil // constructors return void - ) - let key = ":\(descriptor)" + let key = jvmMethodDescriptor(constructor) return methodAnnotations[key] ?? [] } @@ -50,15 +42,4 @@ struct JavaRuntimeInvisibleAnnotations { func annotationsFor(field name: String) -> [JavaRuntimeInvisibleAnnotation] { fieldAnnotations[name] ?? [] } - - /// Build a JVM type descriptor from parameter types and return type. - /// E.g. `(Ljava/lang/String;)V` for `void doSomething(String)`. - private func jvmDescriptor( - parameterTypes: [JavaClass?], - returnType: JavaClass? - ) -> String { - let params = parameterTypes.map { $0?.descriptorString() ?? "Ljava/lang/Object;" }.joined() - let ret = returnType?.descriptorString() ?? "V" - return "(\(params))\(ret)" - } } diff --git a/Sources/SwiftJavaToolLib/JVMMethodDescriptor.swift b/Sources/SwiftJavaToolLib/JVMDescriptors.swift similarity index 96% rename from Sources/SwiftJavaToolLib/JVMMethodDescriptor.swift rename to Sources/SwiftJavaToolLib/JVMDescriptors.swift index aae5bf76..2f311581 100644 --- a/Sources/SwiftJavaToolLib/JVMMethodDescriptor.swift +++ b/Sources/SwiftJavaToolLib/JVMDescriptors.swift @@ -23,7 +23,7 @@ func jvmMethodDescriptor( returnType: JavaClass? ) -> String { let params = parameterTypes.map { $0?.descriptorString() ?? "Ljava/lang/Object;" }.joined() - let ret = returnType?.descriptorString() ?? "V" + let ret = returnType?.descriptorString() ?? "V" // void return "\(name)(\(params))\(ret)" } diff --git a/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift b/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift index 3d85f453..676e2a49 100644 --- a/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift +++ b/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift @@ -79,7 +79,7 @@ final class JavaClassFileParserTests: XCTestCase { XCTAssertTrue(result.classAnnotations.isEmpty) // Find the annotated method by key prefix - let api30Annotations = result.methodAnnotations.filter { $0.key.hasPrefix("api30Method:") } + let api30Annotations = result.methodAnnotations.filter { $0.key.hasPrefix("api30Method(") } XCTAssertEqual(api30Annotations.count, 1) let annotations = api30Annotations.values.first! XCTAssertEqual(annotations.count, 1) @@ -87,7 +87,7 @@ final class JavaClassFileParserTests: XCTestCase { XCTAssertEqual(annotations[0].elements["api"], 30) // normalMethod should have no annotations - let normalAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix("normalMethod:") } + let normalAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix("normalMethod(") } XCTAssertTrue(normalAnnotations.isEmpty) } @@ -121,22 +121,22 @@ final class JavaClassFileParserTests: XCTestCase { let classesDir = try await CompileJavaTool.compileJavaMultiFile([ "androidx/annotation/RequiresApi.java": Self.RequiresApi_ClassRetention, - "com/example/CtorExample.java": + "com/example/ConstructorExample.java": """ package com.example; import androidx.annotation.RequiresApi; - public class CtorExample { + public class ConstructorExample { @RequiresApi(api = 31) - public CtorExample() {} + public ConstructorExample() {} } """, ]) - let classFileURL = classesDir.appendingPathComponent("com/example/CtorExample.class") + let classFileURL = classesDir.appendingPathComponent("com/example/ConstructorExample.class") let bytes = Array(try Data(contentsOf: classFileURL)) let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) - let ctorAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix(":") } + let ctorAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix("") } XCTAssertEqual(ctorAnnotations.count, 1) let annotations = ctorAnnotations.values.first! XCTAssertEqual(annotations.count, 1)