Skip to content

Commit 47030cc

Browse files
committed
Make Android NDK discovery more robust, and add tests
Add support for Android NDK versions 23 through 26, and emit an explicit error for older versions. Add test coverage to verify parsing of the metadata. I chose 23 as the cutoff for now simply because that's the next last time that the abis.json schema changed. That version was released in August 2021. We can add older versions if anyone really wants them.
1 parent 9dbe6df commit 47030cc

File tree

3 files changed

+523
-80
lines changed

3 files changed

+523
-80
lines changed

Sources/SWBAndroidPlatform/AndroidSDK.swift

Lines changed: 155 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -10,118 +10,197 @@
1010
//
1111
//===----------------------------------------------------------------------===//
1212

13-
import SWBUtil
14-
import Foundation
13+
public import SWBUtil
14+
public import Foundation
1515

16-
struct AndroidSDK: Sendable {
16+
@_spi(Testing) public struct AndroidSDK: Sendable {
1717
public let host: OperatingSystem
1818
public let path: Path
19-
public let ndkVersion: Version?
19+
20+
/// List of NDKs available in this SDK installation, sorted by version number from oldest to newest.
21+
@_spi(Testing) public let ndks: [NDK]
22+
23+
public var latestNDK: NDK? {
24+
ndks.last
25+
}
2026

2127
init(host: OperatingSystem, path: Path, fs: any FSProxy) throws {
2228
self.host = host
2329
self.path = path
30+
self.ndks = try NDK.findInstallations(host: host, sdkPath: path, fs: fs)
31+
}
2432

25-
let ndkBasePath = path.join("ndk")
26-
if fs.exists(ndkBasePath) {
27-
self.ndkVersion = try fs.listdir(ndkBasePath).map { try Version($0) }.max()
28-
} else {
29-
self.ndkVersion = nil
30-
}
33+
@_spi(Testing) public struct NDK: Equatable, Sendable {
34+
public static let minimumNDKVersion = Version(23)
35+
36+
public let host: OperatingSystem
37+
public let path: Path
38+
public let version: Version
39+
public let abis: [String: ABI]
40+
public let deploymentTargetRange: DeploymentTargetRange
41+
42+
init(host: OperatingSystem, path ndkPath: Path, version: Version, fs: any FSProxy) throws {
43+
self.host = host
44+
self.path = ndkPath
45+
self.version = version
3146

32-
if let ndkVersion {
33-
let ndkPath = ndkBasePath.join(ndkVersion.description)
3447
let metaPath = ndkPath.join("meta")
3548

36-
self.abis = try JSONDecoder().decode([String: ABI].self, from: Data(fs.read(metaPath.join("abis.json"))))
49+
guard #available(macOS 14, *) else {
50+
throw StubError.error("Unsupported macOS version")
51+
}
52+
53+
if version < Self.minimumNDKVersion {
54+
throw StubError.error("Android NDK version at path '\(ndkPath.str)' is not supported (r\(Self.minimumNDKVersion.description) or later required)")
55+
}
56+
57+
self.abis = try JSONDecoder().decode(ABIs.self, from: Data(fs.read(metaPath.join("abis.json"))), configuration: version).abis
3758

3859
struct PlatformsInfo: Codable {
3960
let min: Int
4061
let max: Int
4162
}
4263

4364
let platformsInfo = try JSONDecoder().decode(PlatformsInfo.self, from: Data(fs.read(metaPath.join("platforms.json"))))
44-
self.ndkPath = ndkPath
45-
deploymentTargetRange = (platformsInfo.min, platformsInfo.max)
46-
} else {
47-
ndkPath = nil
48-
deploymentTargetRange = nil
49-
abis = nil
65+
deploymentTargetRange = DeploymentTargetRange(min: platformsInfo.min, max: platformsInfo.max)
5066
}
51-
}
5267

53-
struct ABI: Codable {
54-
enum Bitness: Int, Codable {
55-
case bits32 = 32
56-
case bits64 = 64
68+
struct ABIs: DecodableWithConfiguration {
69+
let abis: [String: ABI]
70+
71+
init(from decoder: any Decoder, configuration: Version) throws {
72+
struct DynamicCodingKey: CodingKey {
73+
var stringValue: String
74+
75+
init?(stringValue: String) {
76+
self.stringValue = stringValue
77+
}
78+
79+
let intValue: Int? = nil
80+
81+
init?(intValue: Int) {
82+
nil
83+
}
84+
}
85+
let container = try decoder.container(keyedBy: DynamicCodingKey.self)
86+
abis = try Dictionary(uniqueKeysWithValues: container.allKeys.map { try ($0.stringValue, container.decode(ABI.self, forKey: $0, configuration: configuration)) })
87+
}
5788
}
5889

59-
struct LLVMTriple: Codable {
60-
var arch: String
61-
var vendor: String
62-
var system: String
63-
var environment: String
64-
65-
var description: String {
66-
"\(arch)-\(vendor)-\(system)-\(environment)"
90+
@_spi(Testing) public struct ABI: DecodableWithConfiguration, Equatable, Sendable {
91+
@_spi(Testing) public enum Bitness: Int, Codable, Equatable, Sendable {
92+
case bits32 = 32
93+
case bits64 = 64
6794
}
6895

69-
init(from decoder: any Decoder) throws {
70-
let container = try decoder.singleValueContainer()
71-
let triple = try container.decode(String.self)
72-
if let match = try #/(?<arch>.+)-(?<vendor>.+)-(?<system>.+)-(?<environment>.+)/#.wholeMatch(in: triple) {
73-
self.arch = String(match.output.arch)
74-
self.vendor = String(match.output.vendor)
75-
self.system = String(match.output.system)
76-
self.environment = String(match.output.environment)
77-
} else {
78-
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid triple string: \(triple)")
96+
@_spi(Testing) public struct LLVMTriple: Codable, Equatable, Sendable {
97+
public var arch: String
98+
public var vendor: String
99+
public var system: String
100+
public var environment: String
101+
102+
var description: String {
103+
"\(arch)-\(vendor)-\(system)-\(environment)"
104+
}
105+
106+
public init(from decoder: any Decoder) throws {
107+
let container = try decoder.singleValueContainer()
108+
let triple = try container.decode(String.self)
109+
if let match = try #/(?<arch>.+)-(?<vendor>.+)-(?<system>.+)-(?<environment>.+)/#.wholeMatch(in: triple) {
110+
self.arch = String(match.output.arch)
111+
self.vendor = String(match.output.vendor)
112+
self.system = String(match.output.system)
113+
self.environment = String(match.output.environment)
114+
} else {
115+
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid triple string: \(triple)")
116+
}
79117
}
80118
}
81119

82-
func encode(to encoder: any Encoder) throws {
83-
var container = encoder.singleValueContainer()
84-
try container.encode(description)
120+
public let bitness: Bitness
121+
public let `default`: Bool
122+
public let deprecated: Bool
123+
public let proc: String
124+
public let arch: String
125+
public let triple: String
126+
public let llvm_triple: LLVMTriple
127+
public let min_os_version: Int
128+
129+
enum CodingKeys: String, CodingKey {
130+
case bitness
131+
case `default` = "default"
132+
case deprecated
133+
case proc
134+
case arch
135+
case triple
136+
case llvm_triple = "llvm_triple"
137+
case min_os_version = "min_os_version"
138+
}
139+
140+
public init(from decoder: any Decoder, configuration ndkVersion: Version) throws {
141+
let container = try decoder.container(keyedBy: CodingKeys.self)
142+
self.bitness = try container.decode(Bitness.self, forKey: .bitness)
143+
self.default = try container.decode(Bool.self, forKey: .default)
144+
self.deprecated = try container.decode(Bool.self, forKey: .deprecated)
145+
self.proc = try container.decode(String.self, forKey: .proc)
146+
self.arch = try container.decode(String.self, forKey: .arch)
147+
self.triple = try container.decode(String.self, forKey: .triple)
148+
self.llvm_triple = try container.decode(LLVMTriple.self, forKey: .llvm_triple)
149+
self.min_os_version = try container.decodeIfPresent(Int.self, forKey: .min_os_version) ?? {
150+
if ndkVersion < Version(27) {
151+
return 21 // min_os_version wasn't present prior to NDKr27, fill it in with 21, which is the appropriate value
152+
} else {
153+
throw DecodingError.valueNotFound(Int.self, .init(codingPath: container.codingPath, debugDescription: "No value associated with key \(CodingKeys.min_os_version) (\"\(CodingKeys.min_os_version.rawValue)\")."))
154+
}
155+
}()
85156
}
86157
}
87158

88-
let bitness: Bitness
89-
let `default`: Bool
90-
let deprecated: Bool
91-
let proc: String
92-
let arch: String
93-
let triple: String
94-
let llvm_triple: LLVMTriple
95-
let min_os_version: Int
96-
}
159+
@_spi(Testing) public struct DeploymentTargetRange: Equatable, Sendable {
160+
public let min: Int
161+
public let max: Int
162+
}
97163

98-
public let abis: [String: ABI]?
164+
public var toolchainPath: Path {
165+
path.join("toolchains").join("llvm").join("prebuilt").join(hostTag)
166+
}
99167

100-
public let deploymentTargetRange: (min: Int, max: Int)?
168+
public var sysroot: Path {
169+
toolchainPath.join("sysroot")
170+
}
101171

102-
public let ndkPath: Path?
172+
private var hostTag: String? {
173+
switch host {
174+
case .windows:
175+
// Also works on Windows on ARM via Prism binary translation.
176+
"windows-x86_64"
177+
case .macOS:
178+
// Despite the x86_64 tag in the Darwin name, these are universal binaries including arm64.
179+
"darwin-x86_64"
180+
case .linux:
181+
// Also works on non-x86 archs via binfmt support and qemu (or Rosetta on Apple-hosted VMs).
182+
"linux-x86_64"
183+
default:
184+
nil // unsupported host
185+
}
186+
}
103187

104-
public var toolchainPath: Path? {
105-
ndkPath?.join("toolchains").join("llvm").join("prebuilt").join(hostTag)
106-
}
188+
public static func findInstallations(host: OperatingSystem, sdkPath: Path, fs: any FSProxy) throws -> [NDK] {
189+
let ndkBasePath = sdkPath.join("ndk")
190+
guard fs.exists(ndkBasePath) else {
191+
return []
192+
}
107193

108-
public var sysroot: Path? {
109-
toolchainPath?.join("sysroot")
110-
}
194+
let ndks = try fs.listdir(ndkBasePath).map({ try Version($0) }).sorted()
195+
let supportedNdks = ndks.filter { $0 >= minimumNDKVersion }
111196

112-
private var hostTag: String? {
113-
switch host {
114-
case .windows:
115-
// Also works on Windows on ARM via Prism binary translation.
116-
"windows-x86_64"
117-
case .macOS:
118-
// Despite the x86_64 tag in the Darwin name, these are universal binaries including arm64.
119-
"darwin-x86_64"
120-
case .linux:
121-
// Also works on non-x86 archs via binfmt support and qemu (or Rosetta on Apple-hosted VMs).
122-
"linux-x86_64"
123-
default:
124-
nil // unsupported host
197+
// If we have some NDKs but all of them are unsupported, try parsing them so that parsing fails and provides a more useful error. Otherwise, simply filter out and ignore the unsupported versions.
198+
let discoveredNdks = supportedNdks.isEmpty && !ndks.isEmpty ? ndks : supportedNdks
199+
200+
return try discoveredNdks.map { ndkVersion in
201+
let ndkPath = ndkBasePath.join(ndkVersion.description)
202+
return try NDK(host: host, path: ndkPath, version: ndkVersion, fs: fs)
203+
}
125204
}
126205
}
127206

Sources/SWBAndroidPlatform/Plugin.swift

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ struct AndroidEnvironmentExtension: EnvironmentExtension {
5454
if let latest = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first {
5555
return [
5656
"ANDROID_SDK_ROOT": latest.path.str,
57-
"ANDROID_NDK_ROOT": latest.ndkPath?.str,
57+
"ANDROID_NDK_ROOT": latest.ndks.last?.path.str,
5858
].compactMapValues { $0 }
5959
}
6060
default:
@@ -112,10 +112,13 @@ struct AndroidSDKRegistryExtension: SDKRegistryExtension {
112112
return []
113113
}
114114

115-
guard let abis = androidSdk.abis, let deploymentTargetRange = androidSdk.deploymentTargetRange else {
115+
guard let androidNdk = androidSdk.latestNDK else {
116116
return []
117117
}
118118

119+
let abis = androidNdk.abis
120+
let deploymentTargetRange = androidNdk.deploymentTargetRange
121+
119122
let allPossibleTriples = abis.values.flatMap { abi in
120123
(max(deploymentTargetRange.min, abi.min_os_version)...deploymentTargetRange.max).map { deploymentTarget in
121124
var triple = abi.llvm_triple
@@ -147,7 +150,7 @@ struct AndroidSDKRegistryExtension: SDKRegistryExtension {
147150
swiftSettings = [:]
148151
}
149152

150-
return [(androidSdk.sysroot ?? .root, androidPlatform, [
153+
return [(androidNdk.sysroot, androidPlatform, [
151154
"Type": .plString("SDK"),
152155
"Version": .plString("0.0.0"),
153156
"CanonicalName": .plString("android"),
@@ -184,7 +187,7 @@ struct AndroidToolchainRegistryExtension: ToolchainRegistryExtension {
184187
let plugin: AndroidPlugin
185188

186189
func additionalToolchains(context: any ToolchainRegistryExtensionAdditionalToolchainsContext) async throws -> [Toolchain] {
187-
guard let toolchainPath = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first?.toolchainPath else {
190+
guard let toolchainPath = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first?.latestNDK?.toolchainPath else {
188191
return []
189192
}
190193

0 commit comments

Comments
 (0)