From 83e299b6122c4dee7e2beb540447e0855c624297 Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Fri, 6 Mar 2026 09:44:02 +0900 Subject: [PATCH 1/3] move InputStream to SwiftJava for now since we need it in order to getResourceAsStream --- Sources/JavaStdlib/JavaIO/swift-java.config | 1 - .../JavaIO => SwiftJava}/generated/InputStream.swift | 0 Sources/SwiftJava/swift-java.config | 3 ++- 3 files changed, 2 insertions(+), 2 deletions(-) rename Sources/{JavaStdlib/JavaIO => SwiftJava}/generated/InputStream.swift (100%) diff --git a/Sources/JavaStdlib/JavaIO/swift-java.config b/Sources/JavaStdlib/JavaIO/swift-java.config index 1b8c152f..671e323d 100644 --- a/Sources/JavaStdlib/JavaIO/swift-java.config +++ b/Sources/JavaStdlib/JavaIO/swift-java.config @@ -1,6 +1,5 @@ { "classes" : { - "java.io.InputStream" : "InputStream", "java.io.BufferedInputStream" : "BufferedInputStream", "java.io.InputStreamReader" : "InputStreamReader", diff --git a/Sources/JavaStdlib/JavaIO/generated/InputStream.swift b/Sources/SwiftJava/generated/InputStream.swift similarity index 100% rename from Sources/JavaStdlib/JavaIO/generated/InputStream.swift rename to Sources/SwiftJava/generated/InputStream.swift diff --git a/Sources/SwiftJava/swift-java.config b/Sources/SwiftJava/swift-java.config index fcd8a6c4..42610164 100644 --- a/Sources/SwiftJava/swift-java.config +++ b/Sources/SwiftJava/swift-java.config @@ -47,7 +47,8 @@ "java.io.OutputStream": "OutputStream", "java.io.Writer": "Writer", "java.io.PrintWriter": "PrintWriter", - "java.io.StringWriter": "StringWriter" + "java.io.StringWriter": "StringWriter", + "java.io.InputStream": "InputStream" } } From 115922b8a781efe0bbe96645042e43cd799538cd Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Fri, 6 Mar 2026 12:31:49 +0900 Subject: [PATCH 2/3] wrap-java: Basic classfile parser to get CLASS retained annotations Implement basic class file parsing, only enough to get annotations Thankfully the classfile format is well known and documented. We avoid the usual "Java way" of doing this work which would be to pull in the asm.jar dependency, but instead choose to do the parsing ourselfes. The format is well specified and stable, so we can be pretty confident in it. I based it on the JDK22 revision of the spec: https://docs.oracle.com/javase/specs/jvms/se22/html/jvms-4.html We only forus on the "RuntimeInvisibleAannotations" attribute because runtime visible ones we're able to get from plain reflection calls (which the Deprecated test cases showcase already in previous PR): https://docs.oracle.com/javase/specs/jvms/se22/html/jvms-4.html#jvms-4.7.17 This is necessary to support Android's RequiredApi annotations which are CLASS retained (or how kotlin confusingly calls it BINARY which mislead me and I thouhgt they're retained but yeah makes sense). This way we're able to emit Android availability annotations for the whole Android SDK. --- Sources/SwiftJava/generated/JavaClass.swift | 3 + .../SwiftJava/generated/JavaClassLoader.swift | 6 + .../ClassParsing/JavaClassFileReader.swift | 331 ++++++++++++++++++ .../ClassParsing/JavaConstantPool.swift | 34 ++ .../JavaRuntimeInvisibleAnnotation.swift | 33 ++ .../JavaRuntimeInvisibleAnnotations.swift | 64 ++++ .../JavaClassTranslator.swift | 105 +++++- .../CompileJavaTool.swift | 129 +++++++ .../CompileJavaWrapTools.swift | 64 ++-- .../JavaClassFileParserTests.swift | 201 +++++++++++ .../AnnotationsWrapJavaTests.swift | 296 ++++++++++++---- 11 files changed, 1133 insertions(+), 133 deletions(-) create mode 100644 Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift create mode 100644 Sources/SwiftJavaToolLib/ClassParsing/JavaConstantPool.swift create mode 100644 Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotation.swift create mode 100644 Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift create mode 100644 Tests/SwiftJavaToolLibTests/CompileJavaTool.swift create mode 100644 Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift diff --git a/Sources/SwiftJava/generated/JavaClass.swift b/Sources/SwiftJava/generated/JavaClass.swift index 7497e2a5..be475227 100644 --- a/Sources/SwiftJava/generated/JavaClass.swift +++ b/Sources/SwiftJava/generated/JavaClass.swift @@ -137,6 +137,9 @@ open class JavaClass: JavaObject { @JavaMethod open func getNestMembers() -> [JavaClass?] + + @JavaMethod + open func getResourceAsStream(_ arg0: String) -> InputStream! } extension JavaClass { @JavaStaticMethod diff --git a/Sources/SwiftJava/generated/JavaClassLoader.swift b/Sources/SwiftJava/generated/JavaClassLoader.swift index 827bb127..b0eaea18 100644 --- a/Sources/SwiftJava/generated/JavaClassLoader.swift +++ b/Sources/SwiftJava/generated/JavaClassLoader.swift @@ -59,6 +59,12 @@ open class JavaClassLoader: JavaObject { @JavaMethod open func clearAssertionStatus() + + @JavaMethod + open func getResourceAsStream(_ arg0: String) -> InputStream! + + @JavaMethod + open func getResource(_ arg0: String) -> JavaObject! } extension JavaClass { @JavaStaticMethod diff --git a/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift b/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift new file mode 100644 index 00000000..957e5071 --- /dev/null +++ b/Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift @@ -0,0 +1,331 @@ +//===----------------------------------------------------------------------===// +// +// 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 +// +//===----------------------------------------------------------------------===// + +/// Minimal parser for JVM `.class` files (JVM Spec §4). +/// +/// We only handle the bare minimum subset that swift-java is interested in, and skip everything else. +struct JavaClassFileReader { + private var bytes: [UInt8] + private var offset: Int = 0 + + /// Utf8 constant pool entries, indexed by CP index (1-based). + private var utf8Constants: [Int: String] = [:] + /// Integer constant pool entries, indexed by CP index (1-based). + private var integerConstants: [Int: Int32] = [:] + + /// The parsed RuntimeInvisibleAnnotations from the class file. + private(set) var runtimeInvisibleAnnotations = JavaRuntimeInvisibleAnnotations() +} + +extension JavaClassFileReader { + + /// Parse a `.class` file and return the invisible annotations found. + static func parseRuntimeInvisibleAnnotations(_ bytes: [UInt8]) -> JavaRuntimeInvisibleAnnotations { + var reader = JavaClassFileReader(bytes: bytes) + reader.parseClassFile() + return reader.runtimeInvisibleAnnotations + } +} + +// ===== ---------------------------------------------------------------------- +// MARK: Low-level reading/skipping + +extension JavaClassFileReader { + private mutating func readU1() -> UInt8 { + let value = bytes[offset] + offset += 1 + return value + } + + private mutating func readU2() -> UInt16 { + let hi = UInt16(readU1()) + let lo = UInt16(readU1()) + return (hi << 8) | lo + } + + private mutating func readU4() -> UInt32 { + let hi = UInt32(readU2()) + let lo = UInt32(readU2()) + return (hi << 16) | lo + } + + private mutating func skip(_ count: Int) { + offset += count + } +} + +// ===== ---------------------------------------------------------------------- +// MARK: Parsing entities + +extension JavaClassFileReader { + + /// Parse the class file version numbers (§4.1). + /// Returns `(major, minor)` version. + private mutating func parseClassFileVersion() -> (major: UInt16, minor: UInt16) { + let minor = readU2() + let major = readU2() + return (major, minor) + } + + private mutating func parseClassFile() { + // Magic number + let magic = readU4() + guard magic == 0xCAFE_BABE else { return } + + // Version + _ = parseClassFileVersion() + + // Constant pool + let cpCount = Int(readU2()) + parseConstantPool(count: cpCount) + + // Access flags, this_class, super_class + _ = readU2() // access_flags + _ = readU2() // this_class + _ = readU2() // super_class + + // Interfaces + let interfacesCount = Int(readU2()) + skip(interfacesCount * 2) + + // Fields + let fieldsCount = Int(readU2()) + for _ in 0.. (String, [JavaRuntimeInvisibleAnnotation]) { + _ = readU2() // access_flags + let nameIndex = Int(readU2()) + let descriptorIndex = Int(readU2()) + let name = utf8Constants[nameIndex] ?? "" + let key: String + if includeDescriptor { + let descriptor = utf8Constants[descriptorIndex] ?? "" + key = "\(name):\(descriptor)" + } else { + key = name + } + let annotations = parseAttributes() + return (key, annotations) + } + + /// Parse an attributes table and return any annotations found in + /// `RuntimeInvisibleAnnotations` attributes. + private mutating func parseAttributes() -> [JavaRuntimeInvisibleAnnotation] { + let attributesCount = Int(readU2()) + var collected: [JavaRuntimeInvisibleAnnotation] = [] + + for _ in 0.. [JavaRuntimeInvisibleAnnotation] { + let numAnnotations = Int(readU2()) + var annotations: [JavaRuntimeInvisibleAnnotation] = [] + for _ in 0.. JavaRuntimeInvisibleAnnotation { + let typeIndex = Int(readU2()) + let typeDescriptor = utf8Constants[typeIndex] ?? "" + let numPairs = Int(readU2()) + var elements: [String: Int32] = [:] + + for _ in 0.. Int32? { + guard let tag = ElementValueTag(rawValue: readU1()) else { + return nil + } + switch tag { + case .byte, .char, .double, .float, + .long, .short, .boolean, .string: + _ = readU2() // const_value_index + return nil + + case .int: + let constIndex = Int(readU2()) + return integerConstants[constIndex] + + case .enumConstant: + _ = readU2() // type_name_index + _ = readU2() // const_name_index + return nil + + case .classInfo: + _ = readU2() // class_info_index + return nil + + case .annotation: + _ = parseAnnotation() + return nil + + case .array: + let numValues = Int(readU2()) + for _ in 0.. value. Only integer values are captured. + let elements: [String: Int32] + + /// The fully-qualified Java class name derived from `typeDescriptor`, + /// e.g. "androidx.annotation.RequiresApi". + var fullyQualifiedName: String { + // Strip leading 'L' and trailing ';', then replace '/' with '.' + var name = typeDescriptor + if name.hasPrefix("L") { name = String(name.dropFirst()) } + if name.hasSuffix(";") { name = String(name.dropLast()) } + return name.replacing("/", with: ".") + } +} diff --git a/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift new file mode 100644 index 00000000..778a7aba --- /dev/null +++ b/Sources/SwiftJavaToolLib/ClassParsing/JavaRuntimeInvisibleAnnotations.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// +// 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 JavaLangReflect +import SwiftJava + +/// Results of scanning a .class file for RuntimeInvisibleAnnotations. +struct JavaRuntimeInvisibleAnnotations { + /// Annotations on the class itself. + var classAnnotations: [JavaRuntimeInvisibleAnnotation] = [] + + /// Annotations keyed by method name + descriptor, e.g. "api30Method:()V" + var methodAnnotations: [String: [JavaRuntimeInvisibleAnnotation]] = [:] + + /// Annotations keyed by field name, e.g. "OLD_VALUE" + var fieldAnnotations: [String: [JavaRuntimeInvisibleAnnotation]] = [:] + + /// Returns annotations for a Java method, matched by name and exact descriptor. + func annotationsFor(method javaMethod: Method) -> [JavaRuntimeInvisibleAnnotation] { + let descriptor = Self.jvmDescriptor( + parameterTypes: javaMethod.getParameterTypes(), + returnType: javaMethod.getReturnType() + ) + let key = "\(javaMethod.getName()):\(descriptor)" + return methodAnnotations[key] ?? [] + } + + /// Returns annotations for a Java constructor, matched by exact descriptor. + func annotationsFor(constructor: some Executable) -> [JavaRuntimeInvisibleAnnotation] { + let descriptor = Self.jvmDescriptor( + parameterTypes: constructor.getParameterTypes(), + returnType: nil // constructors return void + ) + let key = ":\(descriptor)" + return methodAnnotations[key] ?? [] + } + + /// Returns all annotations for a field with the given name. + func annotationsFor(field name: String) -> [JavaRuntimeInvisibleAnnotation] { + fieldAnnotations[name] ?? [] + } + + /// Build a JVM method descriptor from parameter types and return type. + /// E.g. `(Ljava/lang/String;)V` for `void doSomething(String)`. + private static 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/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index a30a4f51..5703e1f6 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -59,9 +59,14 @@ struct JavaClassTranslator { /// The Swift names of the interfaces that this class implements. let swiftInterfaces: [String] - /// The annotations of the Java class + /// The annotations of the Java class. + /// In other words, RUNTIME retained annotations, visible through reflection. let annotations: [Annotation] + /// Annotations parsed from the .class file's RuntimeInvisibleAnnotations attribute. + /// These are CLASS retained annotations, not visible through reflection. + let runtimeInvisibleAnnotations: JavaRuntimeInvisibleAnnotations + /// The (instance) fields of the Java class. var fields: [Field] = [] @@ -192,6 +197,27 @@ struct JavaClassTranslator { self.annotations = javaClass.getAnnotations().compactMap(\.self) + // Parse RuntimeInvisibleAnnotations (CLASS-retention) from .class bytes. + let resourcePath = fullName.replacing(".", with: "/") + ".class" // must not have leading `/` + if let inputStream = javaClass.getClassLoader()?.getResourceAsStream(resourcePath) { + do { + let bytes = try inputStream.readAllBytes() + self.runtimeInvisibleAnnotations = JavaClassFileReader.parseRuntimeInvisibleAnnotations( + bytes.map { UInt8(bitPattern: $0) } + ) + 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") + } catch { + translator.log.warning("Failed to read .class bytes for '\(fullName)': \(error)") + self.runtimeInvisibleAnnotations = JavaRuntimeInvisibleAnnotations() + } + } else { + translator.log.warning("Could not get resource stream for '\(resourcePath)'") + self.runtimeInvisibleAnnotations = JavaRuntimeInvisibleAnnotations() + } + // Collect all of the class members that we will need to translate. // TODO: Switch over to "declared" versions of these whenever we don't need // to see inherited members. @@ -441,7 +467,10 @@ extension JavaClassTranslator { // Emit the struct declaration describing the java class. let classOrInterface: String = isInterface ? "JavaInterface" : "JavaClass" let introducer = translateAsClass ? "open class" : "public struct" - let classAvailableAttributes = swiftAvailableAttributes(from: annotations) + let classAvailableAttributes = swiftAvailableAttributes( + from: annotations, + runtimeInvisibleAnnotations: self.runtimeInvisibleAnnotations.classAnnotations + ) var classDecl: DeclSyntax = """ \(raw: classAvailableAttributes.render())@\(raw: classOrInterface)(\(literal: javaClass.getName())\(raw: extendsClause)\(raw: interfacesStr)) @@ -624,26 +653,32 @@ extension JavaClassTranslator { } /// Build Swift `@available` attributes from Java annotations on a reflective element. - private func swiftAvailableAttributes(from annotations: [Annotation]) -> SwiftAvailableAttributes { + private func swiftAvailableAttributes( + from annotations: [Annotation], + runtimeInvisibleAnnotations: [JavaRuntimeInvisibleAnnotation] = [] + ) -> SwiftAvailableAttributes { var result = SwiftAvailableAttributes() + var foundRequiresApi = false + + func apiLevelComment(_ level: Int32) -> String { + AndroidAPILevel(rawValue: Int(level)).map { " /* \($0.name) */" } ?? "" + } for annotation in annotations { guard let annotationClass = annotation.annotationType() else { continue } if annotationClass.isKnown(.javaLangDeprecated) { result.attributes.append(SwiftAttribute(value: "@available(*, deprecated)")) - continue - } - - if annotationClass.isKnown(.androidxRequiresApi) || annotationClass.isKnown(.androidSupportRequiresApi) { - func apiLevelComment(_ level: Int32) -> String { - AndroidAPILevel(rawValue: Int(level)).map { " /* \($0.name) */" } ?? "" - } + } else if annotationClass.isKnown(.androidxRequiresApi) || annotationClass.isKnown(.androidSupportRequiresApi) { + foundRequiresApi = true // The annotation proxy exposes api() which returns the resolved integer value. // Build.VERSION_CODES constants are resolved at compile time by javac. let apiLevel = try? annotation.as(JavaObject.self) .dynamicJavaMethodCall(methodName: "api", resultType: Int32.self) + let value = try? annotation.as(JavaObject.self) + .dynamicJavaMethodCall(methodName: "value", resultType: Int32.self) + if let apiLevel, apiLevel > 1 { result.attributes.append( SwiftAttribute( @@ -651,19 +686,41 @@ extension JavaClassTranslator { minimumCompilerVersion: (6, 3) ) ) - continue + } else if let value, value > 1 { + result.attributes.append( + SwiftAttribute( + value: "@available(Android \(value)\(apiLevelComment(value)), *)", + minimumCompilerVersion: (6, 3) + ) + ) } + } + } - let value = try? annotation.as(JavaObject.self) - .dynamicJavaMethodCall(methodName: "value", resultType: Int32.self) - if let value, value > 1 { + // If @RequiresApi was not found via reflection, check CLASS-retention + // annotations parsed from the .class file. + if !foundRequiresApi { + for binAnnotation in runtimeInvisibleAnnotations { + let fqn = binAnnotation.fullyQualifiedName + guard + fqn == KnownJavaAnnotation.androidxRequiresApi.rawValue + || fqn == KnownJavaAnnotation.androidSupportRequiresApi.rawValue + else { continue } + + if let apiLevel = binAnnotation.elements["api"], apiLevel > 1 { + result.attributes.append( + SwiftAttribute( + value: "@available(Android \(apiLevel)\(apiLevelComment(apiLevel)), *)", + minimumCompilerVersion: (6, 3) + ) + ) + } else if let value = binAnnotation.elements["value"], value > 1 { result.attributes.append( SwiftAttribute( value: "@available(Android \(value)\(apiLevelComment(value)), *)", minimumCompilerVersion: (6, 3) ) ) - continue } } } @@ -707,7 +764,11 @@ extension JavaClassTranslator { let convenienceModifier = translateAsClass ? "convenience " : "" let nonoverrideAttribute = translateAsClass ? "@_nonoverride " : "" let constructorAnnotations = javaConstructor.getDeclaredAnnotations().compactMap(\.self) - let availableAttributes = swiftAvailableAttributes(from: constructorAnnotations) + let invisibleCtorAnnotations = runtimeInvisibleAnnotations.annotationsFor(constructor: javaConstructor) + let availableAttributes = swiftAvailableAttributes( + from: constructorAnnotations, + runtimeInvisibleAnnotations: invisibleCtorAnnotations + ) // FIXME: handle generics in constructors return """ @@ -832,7 +893,11 @@ extension JavaClassTranslator { // --- Handle @available attributes from Java annotations (e.g. @Deprecated, @RequiresApi) let methodAnnotations = javaMethod.getDeclaredAnnotations().compactMap(\.self) - let availableAttributes = swiftAvailableAttributes(from: methodAnnotations) + let invisibleMethodAnnotations = runtimeInvisibleAnnotations.annotationsFor(method: javaMethod) + let availableAttributes = swiftAvailableAttributes( + from: methodAnnotations, + runtimeInvisibleAnnotations: invisibleMethodAnnotations + ) // Compute the parameters for '@...JavaMethod(...)' let methodAttribute: AttributeSyntax @@ -930,7 +995,11 @@ extension JavaClassTranslator { let fieldAttribute: AttributeSyntax = javaField.isStatic ? "@JavaStaticField" : "@JavaField" let swiftFieldName = javaField.getName().escapedSwiftName let fieldAnnotations = javaField.getDeclaredAnnotations().compactMap(\.self) - let availableAttributes = swiftAvailableAttributes(from: fieldAnnotations) + let invisibleFieldAnnotations = runtimeInvisibleAnnotations.annotationsFor(field: javaField.getName()) + let availableAttributes = swiftAvailableAttributes( + from: fieldAnnotations, + runtimeInvisibleAnnotations: invisibleFieldAnnotations + ) if let optionalType = typeName.optionalWrappedType() { let setter = diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift new file mode 100644 index 00000000..4c9ca102 --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/CompileJavaTool.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// +// 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 Subprocess + +@testable import SwiftJavaToolLib + +/// Utility for compiling Java source files using javac in tests. +struct CompileJavaTool { + + /// Compiles multiple Java source files together, supporting different packages. + /// + /// - Parameter sourceFiles: A dictionary mapping relative file paths + /// (e.g. `"androidx/annotation/RequiresApi.java"`) to their source text. + /// - Returns: The directory containing compiled `.class` files (the classpath root). + static func compileJavaMultiFile(_ sourceFiles: [String: String]) async throws -> Foundation.URL { + let baseDir = FileManager.default.temporaryDirectory + .appendingPathComponent("swift-java-testing-\(UUID().uuidString)") + let srcDir = baseDir.appendingPathComponent("src") + let classesDir = baseDir.appendingPathComponent("classes") + + try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: classesDir, withIntermediateDirectories: true) + + var filePaths: [String] = [] + for (relativePath, source) in sourceFiles { + let fileURL = srcDir.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try source.write(to: fileURL, atomically: true, encoding: .utf8) + filePaths.append(fileURL.path) + } + + var javacArguments: [String] = ["-d", classesDir.path] + javacArguments.append(contentsOf: filePaths) + + let javacProcess = try await Subprocess.run( + .path(.init("\(javaHome)" + "/bin/javac")), + arguments: .init(javacArguments), + output: .string(limit: Int.max, encoding: UTF8.self), + error: .string(limit: Int.max, encoding: UTF8.self) + ) + + guard javacProcess.terminationStatus.isSuccess else { + let outString = javacProcess.standardOutput ?? "" + let errString = javacProcess.standardError ?? "" + fatalError( + "javac failed (\(javacProcess.terminationStatus));\nOUT: \(outString)\nERROR: \(errString)" + ) + } + + print("Compiled java sources to: \(classesDir)") + return classesDir + } + + /// Compiles a single Java source file. + /// + /// - 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) + + let classesDir = FileManager.default.temporaryDirectory + .appendingPathComponent("swift-java-testing-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: classesDir, withIntermediateDirectories: true) + + let javacProcess = try await Subprocess.run( + .path(.init("\(javaHome)" + "/bin/javac")), + arguments: [ + "-d", classesDir.path, + sourceFile.path, + ], + output: .string(limit: Int.max, encoding: UTF8.self), + error: .string(limit: Int.max, encoding: UTF8.self) + ) + + guard javacProcess.terminationStatus.isSuccess else { + let outString = javacProcess.standardOutput ?? "" + let errString = javacProcess.standardError ?? "" + fatalError( + "javac '\(sourceFile)' failed (\(javacProcess.terminationStatus));\nOUT: \(outString)\nERROR: \(errString)" + ) + } + + print("Compiled java sources to: \(classesDir)") + return classesDir + } + + /// Packages a classes directory into a JAR file. + /// + /// - Parameter classesDir: The directory containing compiled `.class` files. + /// - Returns: The URL of the created JAR file. + static func makeJar(classesDir: Foundation.URL) async throws -> Foundation.URL { + let jarFile = classesDir.deletingLastPathComponent() + .appendingPathComponent("test-\(UUID().uuidString).jar") + + let jarProcess = try await Subprocess.run( + .path(.init("\(javaHome)" + "/bin/jar")), + arguments: ["cf", jarFile.path, "-C", classesDir.path, "."], + output: .string(limit: Int.max, encoding: UTF8.self), + error: .string(limit: Int.max, encoding: UTF8.self) + ) + + guard jarProcess.terminationStatus.isSuccess else { + let outString = jarProcess.standardOutput ?? "" + let errString = jarProcess.standardError ?? "" + fatalError( + "jar failed (\(jarProcess.terminationStatus));\nOUT: \(outString)\nERROR: \(errString)" + ) + } + + print("Created JAR: \(jarFile)") + return jarFile + } +} diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift index e2fad706..0dcc7d43 100644 --- a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift +++ b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift @@ -22,43 +22,9 @@ import SwiftJavaShared import SwiftJavaToolLib import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/issues/43 -private func createTemporaryDirectory(in directory: Foundation.URL) throws -> Foundation.URL { - let uuid = UUID().uuidString - let resolverDirectoryURL = directory.appendingPathComponent("swift-java-testing-\(uuid)") - - try FileManager.default.createDirectory(at: resolverDirectoryURL, withIntermediateDirectories: true, attributes: nil) - - return resolverDirectoryURL -} - /// 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 { - let sourceFile = try TempFile.create(suffix: "java", sourceText) - - let classesDirectory = try createTemporaryDirectory(in: FileManager.default.temporaryDirectory) - - let javacProcess = try await Subprocess.run( - .path(.init("\(javaHome)" + "/bin/javac")), - arguments: [ - "-d", classesDirectory.path, // output directory for .class files - sourceFile.path, - ], - output: .string(limit: Int.max, encoding: UTF8.self), - error: .string(limit: Int.max, encoding: UTF8.self) - ) - - // Check if compilation was successful - guard javacProcess.terminationStatus.isSuccess else { - let outString = javacProcess.standardOutput ?? "" - let errString = javacProcess.standardError ?? "" - fatalError( - "javac '\(sourceFile)' failed (\(javacProcess.terminationStatus));\n" + "OUT: \(outString)\n" - + "ERROR: \(errString)" - ) - } - - print("Compiled java sources to: \(classesDirectory)") - return classesDirectory + try await CompileJavaTool.compileJava(sourceText) } func withJavaTranslator( @@ -93,7 +59,9 @@ func withJavaTranslator( /// each of the expected "chunks" of text. func assertWrapJavaOutput( javaClassNames: [String], + classNameMappings: [String: String] = [:], classpath: [Foundation.URL], + makeJar: Bool = false, assert assertBody: (JavaTranslator) throws -> Void = { _ in }, expectedChunks: [String], function: String = #function, @@ -118,7 +86,27 @@ func assertWrapJavaOutput( translateAsClass: true ) - let classpathJavaURLs = classpath.map({ try! URL.init("\($0)/") }) // we MUST have a trailing slash for JVM to consider it a search directory + let classpathJavaURLs: [JavaNet.URL] + if makeJar { + // Convert each classpath directory into a JAR and use those as classpath entries + classpathJavaURLs = classpath.map { classpathDir in + let jarFile = classpathDir.deletingLastPathComponent() + .appendingPathComponent("test-\(UUID().uuidString).jar") + // Synchronously create the JAR using Process + let process = Process() + process.executableURL = Foundation.URL(fileURLWithPath: "\(javaHome)/bin/jar") + process.arguments = ["cf", jarFile.path, "-C", classpathDir.path, "."] + try! process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + fatalError("jar failed with exit code \(process.terminationStatus)") + } + print("Created JAR: \(jarFile)") + return try! JavaNet.URL.init("\(jarFile)") + } + } else { + classpathJavaURLs = classpath.map({ try! JavaNet.URL.init("\($0)/") }) // we MUST have a trailing slash for JVM to consider it a search directory + } let classLoader = URLClassLoader(classpathJavaURLs, environment: environment) // FIXME: deduplicate this @@ -137,8 +125,8 @@ func assertWrapJavaOutput( // TODO: especially because nested classes // WrapJavaCommand(). - let swiftUnqualifiedName = javaClassName.javaClassNameToCanonicalName - .defaultSwiftNameForJavaClass + let swiftUnqualifiedName = classNameMappings[javaClassName] + ?? javaClassName.javaClassNameToCanonicalName.defaultSwiftNameForJavaClass translator.translatedClasses[javaClassName] = .init(module: nil, name: swiftUnqualifiedName) diff --git a/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift b/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift new file mode 100644 index 00000000..3d85f453 --- /dev/null +++ b/Tests/SwiftJavaToolLibTests/JavaClassFileParserTests.swift @@ -0,0 +1,201 @@ +//===----------------------------------------------------------------------===// +// +// 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 JavaClassFileParserTests: XCTestCase { + + /// Fake `@RequiresApi` annotation with CLASS retention, matching real AndroidX behavior. + static let RequiresApi_ClassRetention = """ + 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; + } + """ + + func test_parseClassAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/MyClass.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + @RequiresApi(api = 33) + public class MyClass { + public void hello() {} + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/MyClass.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertEqual(result.classAnnotations.count, 1) + let annotation = result.classAnnotations[0] + XCTAssertEqual(annotation.fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(annotation.elements["api"], 33) + } + + func test_parseMethodAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/MethodExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + public class MethodExample { + @RequiresApi(api = 30) + public void api30Method() {} + public void normalMethod() {} + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/MethodExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertTrue(result.classAnnotations.isEmpty) + + // Find the annotated method by key prefix + let api30Annotations = result.methodAnnotations.filter { $0.key.hasPrefix("api30Method:") } + XCTAssertEqual(api30Annotations.count, 1) + let annotations = api30Annotations.values.first! + XCTAssertEqual(annotations.count, 1) + XCTAssertEqual(annotations[0].fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(annotations[0].elements["api"], 30) + + // normalMethod should have no annotations + let normalAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix("normalMethod:") } + XCTAssertTrue(normalAnnotations.isEmpty) + } + + func test_parseFieldAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/FieldExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + public class FieldExample { + @RequiresApi(api = 28) + public static int API_FIELD = 42; + public static int NORMAL_FIELD = 99; + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/FieldExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertEqual(result.fieldAnnotations["API_FIELD"]?.count, 1) + XCTAssertEqual(result.fieldAnnotations["API_FIELD"]?[0].fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(result.fieldAnnotations["API_FIELD"]?[0].elements["api"], 28) + XCTAssertNil(result.fieldAnnotations["NORMAL_FIELD"]) + } + + func test_parseConstructorAnnotation() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/CtorExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + public class CtorExample { + @RequiresApi(api = 31) + public CtorExample() {} + } + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/CtorExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + let ctorAnnotations = result.methodAnnotations.filter { $0.key.hasPrefix(":") } + XCTAssertEqual(ctorAnnotations.count, 1) + let annotations = ctorAnnotations.values.first! + XCTAssertEqual(annotations.count, 1) + XCTAssertEqual(annotations[0].fullyQualifiedName, "androidx.annotation.RequiresApi") + XCTAssertEqual(annotations[0].elements["api"], 31) + } + + func test_parseValueElement() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": + Self.RequiresApi_ClassRetention, + "com/example/ValueExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + @RequiresApi(value = 26) + public class ValueExample {} + """, + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/ValueExample.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertEqual(result.classAnnotations.count, 1) + XCTAssertEqual(result.classAnnotations[0].elements["value"], 26) + // "api" should not be present (or default to 1, but javac omits defaults) + XCTAssertNil(result.classAnnotations[0].elements["api"]) + } + + func test_noRuntimeAnnotations() async throws { + let classesDir = try await CompileJavaTool.compileJavaMultiFile([ + "com/example/PlainClass.java": + """ + package com.example; + public class PlainClass { + @Deprecated + public void oldMethod() {} + public void newMethod() {} + } + """ + ]) + + let classFileURL = classesDir.appendingPathComponent("com/example/PlainClass.class") + let bytes = Array(try Data(contentsOf: classFileURL)) + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + // @Deprecated has RUNTIME retention, so it won't appear in RuntimeInvisibleAnnotations + XCTAssertTrue(result.classAnnotations.isEmpty) + XCTAssertTrue(result.methodAnnotations.isEmpty) + XCTAssertTrue(result.fieldAnnotations.isEmpty) + } + + func test_invalidMagic() { + let bytes: [UInt8] = [0x00, 0x12, 0x34, 0x00] + let result = JavaClassFileReader.parseRuntimeInvisibleAnnotations(bytes) + + XCTAssertTrue(result.classAnnotations.isEmpty) + XCTAssertTrue(result.methodAnnotations.isEmpty) + XCTAssertTrue(result.fieldAnnotations.isEmpty) + } +} diff --git a/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift index d0c9d150..5c51d59e 100644 --- a/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift +++ b/Tests/SwiftJavaToolLibTests/WrapJavaTests/AnnotationsWrapJavaTests.swift @@ -24,6 +24,19 @@ import XCTest // NOTE: Workaround for https://github.com/swiftlang/swift-java/is final class AnnotationsWrapJavaTests: 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: @Deprecated @@ -150,18 +163,44 @@ final class AnnotationsWrapJavaTests: XCTestCase { // MARK: @RequiresApi func testWrapJava_requiresApiMethod() async throws { - let classpathURL = try await compileJavaMultiFile([ - "androidx/annotation/RequiresApi.java": - """ - package androidx.annotation; - import java.lang.annotation.*; - @Retention(RetentionPolicy.RUNTIME) - @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) - public @interface RequiresApi { - int api() default 1; - int value() default 1; + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/ApiLevelExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + class ApiLevelExample { + @RequiresApi(api = 30) + public void api30Method() {} + public void anyApiMethod() {} } """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.ApiLevelExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 30 /* Android 11 */, *) + #endif + @JavaMethod + open func api30Method() + """, + """ + @JavaMethod + open func anyApiMethod() + """, + ] + ) + } + + func testWrapJava_requiresApiMethod_fromJar() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, "com/example/ApiLevelExample.java": """ package com.example; @@ -179,6 +218,7 @@ final class AnnotationsWrapJavaTests: XCTestCase { "com.example.ApiLevelExample" ], classpath: [classpathURL], + makeJar: true, expectedChunks: [ """ #if compiler(>=6.3) @@ -195,22 +235,79 @@ final class AnnotationsWrapJavaTests: XCTestCase { ) } + func testWrapJava_requiresApiField() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/ApiFieldExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + class ApiFieldExample { + @RequiresApi(api = 30) + public static int API30_FIELD = 42; + public static int NORMAL_FIELD = 99; + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.ApiFieldExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 30 /* Android 11 */, *) + #endif + @JavaStaticField(isFinal: false) + public var API30_FIELD: Int32 + """, + """ + @JavaStaticField(isFinal: false) + public var NORMAL_FIELD: Int32 + """, + ] + ) + } + + func testWrapJava_requiresApiConstructor() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/ApiConstructorExample.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + class ApiConstructorExample { + @RequiresApi(api = 33) + public ApiConstructorExample() {} + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.ApiConstructorExample" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaMethod + @_nonoverride public convenience init(environment: JNIEnvironment? = nil) + """ + ] + ) + } + // ==== ------------------------------------------------ // MARK: @Deprecated + @RequiresApi func testWrapJava_deprecatedAndRequiresApi() async throws { - let classpathURL = try await compileJavaMultiFile([ - "androidx/annotation/RequiresApi.java": - """ - package androidx.annotation; - import java.lang.annotation.*; - @Retention(RetentionPolicy.RUNTIME) - @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) - public @interface RequiresApi { - int api() default 1; - int value() default 1; - } - """, + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, "com/example/BothAnnotations.java": """ package com.example; @@ -247,18 +344,8 @@ final class AnnotationsWrapJavaTests: XCTestCase { } func testWrapJava_requiresApiOnClass() async throws { - let classpathURL = try await compileJavaMultiFile([ - "androidx/annotation/RequiresApi.java": - """ - package androidx.annotation; - import java.lang.annotation.*; - @Retention(RetentionPolicy.RUNTIME) - @Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) - public @interface RequiresApi { - int api() default 1; - int value() default 1; - } - """, + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, "com/example/TiramisuClass.java": """ package com.example; @@ -286,53 +373,108 @@ final class AnnotationsWrapJavaTests: XCTestCase { ] ) } -} -// MARK: - Multi-file Java compilation helper - -/// Compiles multiple Java source files together, supporting different packages. -/// -/// - Parameter sourceFiles: A dictionary mapping relative file paths -/// (e.g. `"androidx/annotation/RequiresApi.java"`) to their source text. -/// - Returns: The directory that should be added to the classpath. -private func compileJavaMultiFile(_ sourceFiles: [String: String]) async throws -> Foundation.URL { - let baseDir = FileManager.default.temporaryDirectory - .appendingPathComponent("swift-java-testing-\(UUID().uuidString)") - let srcDir = baseDir.appendingPathComponent("src") - let classesDir = baseDir.appendingPathComponent("classes") - - try FileManager.default.createDirectory(at: srcDir, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: classesDir, withIntermediateDirectories: true) - - var filePaths: [String] = [] - for (relativePath, source) in sourceFiles { - let fileURL = srcDir.appendingPathComponent(relativePath) - try FileManager.default.createDirectory( - at: fileURL.deletingLastPathComponent(), - withIntermediateDirectories: true + func testWrapJava_requiresApiSameSimpleNameDifferentPackages() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/Example.java": + """ + package com.example; + + public class Example { + @androidx.annotation.RequiresApi(api = 28) + public void doWork() {} + } + """, + "com/another/Example.java": + """ + package com.another; + public class Example { + @androidx.annotation.RequiresApi(api = 33) + public void doWork() {} + } + """, + ]) + + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.Example", + "com.another.Example", + ], + classNameMappings: [ + "com.example.Example": "ExampleOne", + "com.another.Example": "ExampleTwo", + ], + classpath: [classpathURL], + expectedChunks: [ + // com.example.Example — API 28 + """ + @JavaClass("com.example.Example") + open class ExampleOne: JavaObject { + """, + """ + #if compiler(>=6.3) + @available(Android 28 /* Pie */, *) + #endif + @JavaMethod + open func doWork() + """, + // com.another.Example — API 33 + """ + @JavaClass("com.another.Example") + open class ExampleTwo: JavaObject { + """, + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaMethod + open func doWork() + """, + ] ) - try source.write(to: fileURL, atomically: true, encoding: .utf8) - filePaths.append(fileURL.path) } - var javacArguments: [String] = ["-d", classesDir.path] - javacArguments.append(contentsOf: filePaths) - - let javacProcess = try await Subprocess.run( - .path(.init("\(javaHome)" + "/bin/javac")), - arguments: .init(javacArguments), - output: .string(limit: Int.max, encoding: UTF8.self), - error: .string(limit: Int.max, encoding: UTF8.self) - ) - - guard javacProcess.terminationStatus.isSuccess else { - let outString = javacProcess.standardOutput ?? "" - let errString = javacProcess.standardError ?? "" - fatalError( - "javac failed (\(javacProcess.terminationStatus));\nOUT: \(outString)\nERROR: \(errString)" + func testWrapJava_requiresApiOnSpecificMethod() async throws { + let classpathURL = try await CompileJavaTool.compileJavaMultiFile([ + "androidx/annotation/RequiresApi.java": Self.requiresApiAnnotationSource, + "com/example/TiramisuClass.java": + """ + package com.example; + import androidx.annotation.RequiresApi; + + class TiramisuClass { + @RequiresApi(api = 11) + public void doSomething(String name) {} + + @RequiresApi(api = 33) + public void doSomething() {} + } + """, + ]) + + // Only the specific overload gets the annotation + try assertWrapJavaOutput( + javaClassNames: [ + "com.example.TiramisuClass" + ], + classpath: [classpathURL], + expectedChunks: [ + """ + #if compiler(>=6.3) + @available(Android 11 /* Honeycomb */, *) + #endif + @JavaMethod + open func doSomething(_ arg0: String) + """, + """ + #if compiler(>=6.3) + @available(Android 33 /* Tiramisu */, *) + #endif + @JavaMethod + open func doSomething() + """, + ] ) } - - print("Compiled java sources to: \(classesDir)") - return classesDir } From 513c0ce33c899262353e70fca6ae22e07b0010e1 Mon Sep 17 00:00:00 2001 From: Konrad Malawski Date: Fri, 6 Mar 2026 22:19:48 +0900 Subject: [PATCH 3/3] format --- .../JavaClassTranslator.swift | 62 +++++-------------- .../CompileJavaWrapTools.swift | 3 +- 2 files changed, 19 insertions(+), 46 deletions(-) diff --git a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift index 5703e1f6..1d3c1a59 100644 --- a/Sources/SwiftJavaToolLib/JavaClassTranslator.swift +++ b/Sources/SwiftJavaToolLib/JavaClassTranslator.swift @@ -654,73 +654,45 @@ extension JavaClassTranslator { /// Build Swift `@available` attributes from Java annotations on a reflective element. private func swiftAvailableAttributes( - from annotations: [Annotation], + from runtimeAnnotations: [Annotation], runtimeInvisibleAnnotations: [JavaRuntimeInvisibleAnnotation] = [] ) -> SwiftAvailableAttributes { var result = SwiftAvailableAttributes() - var foundRequiresApi = false func apiLevelComment(_ level: Int32) -> String { AndroidAPILevel(rawValue: Int(level)).map { " /* \($0.name) */" } ?? "" } - for annotation in annotations { + for annotation in runtimeAnnotations { guard let annotationClass = annotation.annotationType() else { continue } if annotationClass.isKnown(.javaLangDeprecated) { result.attributes.append(SwiftAttribute(value: "@available(*, deprecated)")) - } else if annotationClass.isKnown(.androidxRequiresApi) || annotationClass.isKnown(.androidSupportRequiresApi) { - foundRequiresApi = true - - // The annotation proxy exposes api() which returns the resolved integer value. - // Build.VERSION_CODES constants are resolved at compile time by javac. - let apiLevel = try? annotation.as(JavaObject.self) - .dynamicJavaMethodCall(methodName: "api", resultType: Int32.self) - let value = try? annotation.as(JavaObject.self) - .dynamicJavaMethodCall(methodName: "value", resultType: Int32.self) - - if let apiLevel, apiLevel > 1 { - result.attributes.append( - SwiftAttribute( - value: "@available(Android \(apiLevel)\(apiLevelComment(apiLevel)), *)", - minimumCompilerVersion: (6, 3) - ) - ) - } else if let value, value > 1 { - result.attributes.append( - SwiftAttribute( - value: "@available(Android \(value)\(apiLevelComment(value)), *)", - minimumCompilerVersion: (6, 3) - ) - ) - } } } - // If @RequiresApi was not found via reflection, check CLASS-retention - // annotations parsed from the .class file. - if !foundRequiresApi { - for binAnnotation in runtimeInvisibleAnnotations { - let fqn = binAnnotation.fullyQualifiedName - guard - fqn == KnownJavaAnnotation.androidxRequiresApi.rawValue - || fqn == KnownJavaAnnotation.androidSupportRequiresApi.rawValue - else { continue } + // Look for any annotations stored in classfiles, e.g. the Android @RequiresApi + for binAnnotation in runtimeInvisibleAnnotations { + let fqn = binAnnotation.fullyQualifiedName + 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 apiLevel = binAnnotation.elements["api"], apiLevel > 1 { + if let apiLevel { result.attributes.append( SwiftAttribute( value: "@available(Android \(apiLevel)\(apiLevelComment(apiLevel)), *)", minimumCompilerVersion: (6, 3) ) ) - } else if let value = binAnnotation.elements["value"], value > 1 { - result.attributes.append( - SwiftAttribute( - value: "@available(Android \(value)\(apiLevelComment(value)), *)", - minimumCompilerVersion: (6, 3) - ) - ) } } } diff --git a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift index 0dcc7d43..f1dbc4ee 100644 --- a/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift +++ b/Tests/SwiftJavaToolLibTests/CompileJavaWrapTools.swift @@ -125,7 +125,8 @@ func assertWrapJavaOutput( // TODO: especially because nested classes // WrapJavaCommand(). - let swiftUnqualifiedName = classNameMappings[javaClassName] + let swiftUnqualifiedName = + classNameMappings[javaClassName] ?? javaClassName.javaClassNameToCanonicalName.defaultSwiftNameForJavaClass translator.translatedClasses[javaClassName] = .init(module: nil, name: swiftUnqualifiedName)