Skip to content

Commit cbe9d06

Browse files
authored
Add --android-api-version-file support for availability (#593)
* 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. * add docs about --android-api-version-file
1 parent 763ff47 commit cbe9d06

17 files changed

Lines changed: 1125 additions & 63 deletions

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@ let package = Package(
389389
dependencies: [
390390
"SwiftJava"
391391
],
392+
exclude: ["swift-java.config"],
392393
swiftSettings: [
393394
.swiftLanguageMode(.v5),
394395
.unsafeFlags(["-I\(javaIncludePath)", "-I\(javaPlatformIncludePath)"], .when(platforms: [.macOS, .linux, .windows])),

Sources/SwiftJavaDocumentation/Documentation.docc/Android.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,24 @@ let package = Package(
2121
]
2222
)
2323
```
24+
25+
### Android SDK Availability
26+
27+
When wrapping the Android SDK (`android.jar`) you can provide the optional `--android-api-version-file` option to `swift-java wrap-java`.
28+
29+
This file contains availability information for Android APIs, which swift-java will take into account when generating the wrappers.
30+
All APIs will therefore be annotated with their respective availability, expressed using Swift's `@available`:
31+
32+
```swift
33+
#if compiler(>=6.3)
34+
@available(Android 3 /* Cupcake */, *)
35+
@available(Android, deprecated: 29, message: "Deprecated in Android API 29 /* Android 10 */")
36+
#endif
37+
@JavaClass("com.example.OldVersionedClass")
38+
open class OldVersionedClass: JavaObject {
39+
}
2440
```
41+
42+
Annotations are generated both for "since", "deprecated" and "removed" attributes.
43+
44+
> Note: To use Android platform availability you must use at least Swift 6.3, which introduced the `Android` platform.

Sources/SwiftJavaTool/Commands/WrapJavaCommand.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ extension SwiftJava {
6161

6262
@Option(help: "If specified, a single Swift file will be generated containing all the generated code")
6363
var singleSwiftFileOutput: String?
64+
65+
@Option(name: .customLong("android-api-version-file"), help: "Path to Android api-versions.xml for generating @available attributes based on API level data")
66+
var androidAPIVersionFile: String?
6467
}
6568
}
6669

@@ -141,6 +144,14 @@ extension SwiftJava.WrapJavaCommand {
141144
// Swift-native implementations.
142145
translator.swiftNativeImplementations = Set(swiftNativeImplementation)
143146

147+
// Load Android API version data if provided.
148+
if let androidAPIVersionFile {
149+
let url = URL(fileURLWithPath: androidAPIVersionFile)
150+
let apiVersions = try AndroidAPIVersionsParser.parse(contentsOf: url, log: Self.log)
151+
translator.androidAPIVersions = apiVersions
152+
log.info("Loaded Android API versions: \(apiVersions.stats())")
153+
}
154+
144155
// Note all of the dependent configurations.
145156
for (swiftModuleName, dependentConfig) in dependentConfigs {
146157
translator.addConfiguration(

Sources/SwiftJavaToolLib/AndroidAPILevel.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,13 @@ public enum AndroidAPILevel: Int {
134134
case .CUR_DEVELOPMENT: "CUR_DEVELOPMENT"
135135
}
136136
}
137+
138+
/// Create from an optional string (e.g. an XML attribute value).
139+
/// Returns `nil` if the string is `nil`, not a valid integer, or not a known API level.
140+
public init?(_ string: String?) {
141+
guard let string, let raw = Int(string) else {
142+
return nil
143+
}
144+
self.init(rawValue: raw)
145+
}
137146
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
/// Fully-qualified Java class name in dot format, e.g. `com.example.MyClass`.
16+
package typealias FullyQualifiedClassName = String
17+
18+
/// JVM method descriptor combining method name with parameter and return type descriptors,
19+
/// e.g. `getDisplayId()I` or `<init>(Ljava/lang/String;)V`.
20+
package typealias JVMMethodDescriptor = String
21+
22+
/// Java field name, e.g. `"ACCEPT_HANDOVER"`.
23+
package typealias FieldName = String
24+
25+
/// Version info for a single API element (class, method, or field)
26+
/// as recorded in the Android SDK's `api-versions.xml`.
27+
package struct AndroidAPIAvailability {
28+
/// The API level at which this element was introduced.
29+
package var since: AndroidAPILevel?
30+
/// The API level at which this element was removed.
31+
package var removed: AndroidAPILevel?
32+
/// The API level at which this element was deprecated.
33+
package var deprecated: AndroidAPILevel?
34+
35+
package init(since: AndroidAPILevel? = nil, removed: AndroidAPILevel? = nil, deprecated: AndroidAPILevel? = nil) {
36+
self.since = since
37+
self.removed = removed
38+
self.deprecated = deprecated
39+
}
40+
}
41+
42+
/// Stores the parsed `api-versions.xml` data and provides query methods
43+
/// for looking up version information by class, method, or field.
44+
///
45+
/// Class names are stored internally in dot format (e.g. `"android.Manifest$permission"`).
46+
/// Query methods accept either slash or dot format and convert automatically.
47+
package struct AndroidAPIVersions {
48+
/// class name -> version info
49+
var classVersions: [FullyQualifiedClassName: AndroidAPIAvailability] = [:]
50+
/// class name -> (method descriptor -> version info)
51+
var methodVersions: [FullyQualifiedClassName: [JVMMethodDescriptor: AndroidAPIAvailability]] = [:]
52+
/// class name -> (field name -> version info)
53+
var fieldVersions: [FullyQualifiedClassName: [FieldName: AndroidAPIAvailability]] = [:]
54+
55+
package init() {}
56+
57+
/// Query version info for a class.
58+
package func versionInfo(forClass className: FullyQualifiedClassName) -> AndroidAPIAvailability? {
59+
classVersions[Self.normalizeClassName(className)]
60+
}
61+
62+
/// Query version info for a method within a class.
63+
package func versionInfo(forClass className: FullyQualifiedClassName, methodDescriptor: JVMMethodDescriptor) -> AndroidAPIAvailability? {
64+
methodVersions[Self.normalizeClassName(className)]?[methodDescriptor]
65+
}
66+
67+
/// Query version info for a field within a class.
68+
package func versionInfo(forClass className: FullyQualifiedClassName, fieldName: FieldName) -> AndroidAPIAvailability? {
69+
fieldVersions[Self.normalizeClassName(className)]?[fieldName]
70+
}
71+
72+
/// Statistics about the parsed data.
73+
package func stats() -> Stats {
74+
Stats(
75+
classCount: classVersions.count,
76+
methodCount: methodVersions.values.reduce(0) { $0 + $1.count },
77+
fieldCount: fieldVersions.values.reduce(0) { $0 + $1.count }
78+
)
79+
}
80+
81+
package struct Stats: CustomStringConvertible {
82+
package var classCount: Int
83+
package var methodCount: Int
84+
package var fieldCount: Int
85+
86+
public var description: String {
87+
"\(classCount) classes, \(methodCount) methods, \(fieldCount) fields"
88+
}
89+
}
90+
91+
/// Normalize a class name to dot format, converting slashes if needed.
92+
static func normalizeClassName(_ name: String) -> FullyQualifiedClassName {
93+
name.replacing("/", with: ".")
94+
}
95+
}
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift.org project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift.org project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
import Logging
17+
18+
#if canImport(FoundationXML)
19+
import FoundationXML
20+
#endif
21+
22+
/// Parses an Android `api-versions.xml` file (format version 3) into an ``AndroidAPIVersions``.
23+
24+
public final class AndroidAPIVersionsParser: _AndroidAPIVersionsParserBase, XMLParserDelegate {
25+
private var result = AndroidAPIVersions()
26+
private var currentClassName: String?
27+
private var currentClassSince: AndroidAPILevel?
28+
private var parseError: Error?
29+
30+
private let log: Logger
31+
32+
private init(log: Logger) {
33+
self.log = log
34+
super.init()
35+
}
36+
37+
/// Parse an `api-versions.xml` file from the given URL.
38+
package static func parse(contentsOf url: URL, log: Logger = .noop) throws -> AndroidAPIVersions {
39+
let data = try Data(contentsOf: url)
40+
return try parse(data: data, log: log)
41+
}
42+
43+
/// Parse `api-versions.xml` from in-memory data.
44+
package static func parse(data: Data, log: Logger = .noop) throws -> AndroidAPIVersions {
45+
let handler = AndroidAPIVersionsParser(log: log)
46+
let parser = XMLParser(data: data)
47+
parser.delegate = handler
48+
guard parser.parse() else {
49+
if let error = handler.parseError {
50+
throw error
51+
}
52+
throw parser.parserError ?? AndroidAPIVersionsParserError.unknownParseError
53+
}
54+
if let error = handler.parseError {
55+
throw error
56+
}
57+
return handler.result
58+
}
59+
60+
/// Parse `api-versions.xml` from a string.
61+
package static func parse(string: String, log: Logger = .noop) throws -> AndroidAPIVersions {
62+
let data = Data(string.utf8)
63+
return try parse(data: data, log: log)
64+
}
65+
66+
// ===== ------------------------------------------------------------------------
67+
// MARK: - XMLParserDelegate
68+
69+
public func parser(
70+
_ parser: XMLParser,
71+
didStartElement elementName: String,
72+
namespaceURI: String?,
73+
qualifiedName: String?,
74+
attributes attrs: [String: String]
75+
) {
76+
switch elementName {
77+
case "api": parseAPI(attrs: attrs)
78+
case "class": parseClass(attrs: attrs)
79+
case "method": parseMethod(attrs: attrs)
80+
case "field": parseField(attrs: attrs)
81+
default: break // ignore <sdk>, <extends>, <implements>, etc.
82+
}
83+
}
84+
85+
private func parseAPI(attrs: [String: String]) {
86+
if let versionStr = attrs["version"], versionStr != "3" {
87+
log.warning("api-versions.xml has version '\(versionStr)', expected '3'. Parsing may be incomplete.")
88+
}
89+
}
90+
91+
private func parseClass(attrs: [String: String]) {
92+
guard let name = attrs["name"] else { return }
93+
let dotName = name.replacing("/", with: ".")
94+
currentClassName = dotName
95+
let since = AndroidAPILevel(attrs["since"])
96+
currentClassSince = since
97+
let info = AndroidAPIAvailability(
98+
since: since,
99+
removed: AndroidAPILevel(attrs["removed"]),
100+
deprecated: AndroidAPILevel(attrs["deprecated"])
101+
)
102+
result.classVersions[dotName] = info
103+
}
104+
105+
private func parseMethod(attrs: [String: String]) {
106+
guard let className = currentClassName,
107+
let name = attrs["name"]
108+
else { return }
109+
let info = AndroidAPIAvailability(
110+
since: AndroidAPILevel(attrs["since"]) ?? currentClassSince,
111+
removed: AndroidAPILevel(attrs["removed"]),
112+
deprecated: AndroidAPILevel(attrs["deprecated"])
113+
)
114+
result.methodVersions[className, default: [:]][name] = info
115+
}
116+
117+
private func parseField(attrs: [String: String]) {
118+
guard let className = currentClassName,
119+
let name = attrs["name"]
120+
else { return }
121+
let info = AndroidAPIAvailability(
122+
since: AndroidAPILevel(attrs["since"]) ?? currentClassSince,
123+
removed: AndroidAPILevel(attrs["removed"]),
124+
deprecated: AndroidAPILevel(attrs["deprecated"])
125+
)
126+
result.fieldVersions[className, default: [:]][name] = info
127+
}
128+
129+
public func parser(
130+
_ parser: XMLParser,
131+
didEndElement elementName: String,
132+
namespaceURI: String?,
133+
qualifiedName: String?
134+
) {
135+
if elementName == "class" {
136+
currentClassName = nil
137+
currentClassSince = nil
138+
}
139+
}
140+
141+
public func parser(_ parser: XMLParser, parseErrorOccurred parseError: Error) {
142+
self.parseError = parseError
143+
}
144+
}
145+
146+
// ===== ------------------------------------------------------------------------
147+
// MARK: - Errors
148+
149+
public enum AndroidAPIVersionsParserError: Error, CustomStringConvertible {
150+
case unknownParseError
151+
152+
public var description: String {
153+
switch self {
154+
case .unknownParseError:
155+
"Unknown error parsing api-versions.xml"
156+
}
157+
}
158+
}
159+
160+
// ===== ------------------------------------------------------------------------
161+
// MARK: - Platform base class
162+
163+
/// On Apple platforms `XMLParserDelegate` is an `@objc` protocol, so the class
164+
/// inherits from `NSObject`. On Linux (swift-corelibs-foundation) the protocol
165+
/// is a plain Swift protocol and no base class is needed.
166+
#if canImport(ObjectiveC)
167+
public class _AndroidAPIVersionsParserBase: NSObject {}
168+
#else
169+
public class _AndroidAPIVersionsParserBase {}
170+
#endif
171+
172+
// ===== ------------------------------------------------------------------------
173+
// MARK: - Logger extensions
174+
175+
extension Logger {
176+
/// A logger that silently discards all log messages.
177+
public static var noop: Logger {
178+
Logger(label: "noop", factory: { _ in SwiftLogNoOpLogHandler() })
179+
}
180+
}

Sources/SwiftJavaToolLib/ClassParsing/JavaClassFileReader.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ extension JavaClassFileReader {
214214
let key: String
215215
if includeDescriptor {
216216
let descriptor = utf8Constants[descriptorIndex] ?? ""
217-
key = "\(name):\(descriptor)"
217+
key = "\(name)\(descriptor)"
218218
} else {
219219
key = name
220220
}

0 commit comments

Comments
 (0)