diff --git a/Sources/SWBAndroidPlatform/AndroidSDK.swift b/Sources/SWBAndroidPlatform/AndroidSDK.swift index 44b93114..cb66619d 100644 --- a/Sources/SWBAndroidPlatform/AndroidSDK.swift +++ b/Sources/SWBAndroidPlatform/AndroidSDK.swift @@ -10,30 +10,51 @@ // //===----------------------------------------------------------------------===// -import SWBUtil -import Foundation +public import SWBUtil +public import Foundation -struct AndroidSDK: Sendable { +@_spi(Testing) public struct AndroidSDK: Sendable { public let host: OperatingSystem public let path: Path - public let ndkVersion: Version? + + /// List of NDKs available in this SDK installation, sorted by version number from oldest to newest. + @_spi(Testing) public let ndks: [NDK] + + public var latestNDK: NDK? { + ndks.last + } init(host: OperatingSystem, path: Path, fs: any FSProxy) throws { self.host = host self.path = path + self.ndks = try NDK.findInstallations(host: host, sdkPath: path, fs: fs) + } - let ndkBasePath = path.join("ndk") - if fs.exists(ndkBasePath) { - self.ndkVersion = try fs.listdir(ndkBasePath).map { try Version($0) }.max() - } else { - self.ndkVersion = nil - } + @_spi(Testing) public struct NDK: Equatable, Sendable { + public static let minimumNDKVersion = Version(23) + + public let host: OperatingSystem + public let path: Path + public let version: Version + public let abis: [String: ABI] + public let deploymentTargetRange: DeploymentTargetRange + + init(host: OperatingSystem, path ndkPath: Path, version: Version, fs: any FSProxy) throws { + self.host = host + self.path = ndkPath + self.version = version - if let ndkVersion { - let ndkPath = ndkBasePath.join(ndkVersion.description) let metaPath = ndkPath.join("meta") - self.abis = try JSONDecoder().decode([String: ABI].self, from: Data(fs.read(metaPath.join("abis.json")))) + guard #available(macOS 14, *) else { + throw StubError.error("Unsupported macOS version") + } + + if version < Self.minimumNDKVersion { + throw StubError.error("Android NDK version at path '\(ndkPath.str)' is not supported (r\(Self.minimumNDKVersion.description) or later required)") + } + + self.abis = try JSONDecoder().decode(ABIs.self, from: Data(fs.read(metaPath.join("abis.json"))), configuration: version).abis struct PlatformsInfo: Codable { let min: Int @@ -41,87 +62,145 @@ struct AndroidSDK: Sendable { } let platformsInfo = try JSONDecoder().decode(PlatformsInfo.self, from: Data(fs.read(metaPath.join("platforms.json")))) - self.ndkPath = ndkPath - deploymentTargetRange = (platformsInfo.min, platformsInfo.max) - } else { - ndkPath = nil - deploymentTargetRange = nil - abis = nil + deploymentTargetRange = DeploymentTargetRange(min: platformsInfo.min, max: platformsInfo.max) } - } - struct ABI: Codable { - enum Bitness: Int, Codable { - case bits32 = 32 - case bits64 = 64 + struct ABIs: DecodableWithConfiguration { + let abis: [String: ABI] + + init(from decoder: any Decoder, configuration: Version) throws { + struct DynamicCodingKey: CodingKey { + var stringValue: String + + init?(stringValue: String) { + self.stringValue = stringValue + } + + let intValue: Int? = nil + + init?(intValue: Int) { + nil + } + } + let container = try decoder.container(keyedBy: DynamicCodingKey.self) + abis = try Dictionary(uniqueKeysWithValues: container.allKeys.map { try ($0.stringValue, container.decode(ABI.self, forKey: $0, configuration: configuration)) }) + } } - struct LLVMTriple: Codable { - var arch: String - var vendor: String - var system: String - var environment: String - - var description: String { - "\(arch)-\(vendor)-\(system)-\(environment)" + @_spi(Testing) public struct ABI: DecodableWithConfiguration, Equatable, Sendable { + @_spi(Testing) public enum Bitness: Int, Codable, Equatable, Sendable { + case bits32 = 32 + case bits64 = 64 } - init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let triple = try container.decode(String.self) - if let match = try #/(?.+)-(?.+)-(?.+)-(?.+)/#.wholeMatch(in: triple) { - self.arch = String(match.output.arch) - self.vendor = String(match.output.vendor) - self.system = String(match.output.system) - self.environment = String(match.output.environment) - } else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid triple string: \(triple)") + @_spi(Testing) public struct LLVMTriple: Codable, Equatable, Sendable { + public var arch: String + public var vendor: String + public var system: String + public var environment: String + + var description: String { + "\(arch)-\(vendor)-\(system)-\(environment)" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let triple = try container.decode(String.self) + if let match = try #/(?.+)-(?.+)-(?.+)-(?.+)/#.wholeMatch(in: triple) { + self.arch = String(match.output.arch) + self.vendor = String(match.output.vendor) + self.system = String(match.output.system) + self.environment = String(match.output.environment) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid triple string: \(triple)") + } } } - func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(description) + public let bitness: Bitness + public let `default`: Bool + public let deprecated: Bool + public let proc: String + public let arch: String + public let triple: String + public let llvm_triple: LLVMTriple + public let min_os_version: Int + + enum CodingKeys: String, CodingKey { + case bitness + case `default` = "default" + case deprecated + case proc + case arch + case triple + case llvm_triple = "llvm_triple" + case min_os_version = "min_os_version" + } + + public init(from decoder: any Decoder, configuration ndkVersion: Version) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.bitness = try container.decode(Bitness.self, forKey: .bitness) + self.default = try container.decode(Bool.self, forKey: .default) + self.deprecated = try container.decode(Bool.self, forKey: .deprecated) + self.proc = try container.decode(String.self, forKey: .proc) + self.arch = try container.decode(String.self, forKey: .arch) + self.triple = try container.decode(String.self, forKey: .triple) + self.llvm_triple = try container.decode(LLVMTriple.self, forKey: .llvm_triple) + self.min_os_version = try container.decodeIfPresent(Int.self, forKey: .min_os_version) ?? { + if ndkVersion < Version(27) { + return 21 // min_os_version wasn't present prior to NDKr27, fill it in with 21, which is the appropriate value + } else { + throw DecodingError.valueNotFound(Int.self, .init(codingPath: container.codingPath, debugDescription: "No value associated with key \(CodingKeys.min_os_version) (\"\(CodingKeys.min_os_version.rawValue)\").")) + } + }() } } - let bitness: Bitness - let `default`: Bool - let deprecated: Bool - let proc: String - let arch: String - let triple: String - let llvm_triple: LLVMTriple - let min_os_version: Int - } + @_spi(Testing) public struct DeploymentTargetRange: Equatable, Sendable { + public let min: Int + public let max: Int + } - public let abis: [String: ABI]? + public var toolchainPath: Path { + path.join("toolchains").join("llvm").join("prebuilt").join(hostTag) + } - public let deploymentTargetRange: (min: Int, max: Int)? + public var sysroot: Path { + toolchainPath.join("sysroot") + } - public let ndkPath: Path? + private var hostTag: String? { + switch host { + case .windows: + // Also works on Windows on ARM via Prism binary translation. + "windows-x86_64" + case .macOS: + // Despite the x86_64 tag in the Darwin name, these are universal binaries including arm64. + "darwin-x86_64" + case .linux: + // Also works on non-x86 archs via binfmt support and qemu (or Rosetta on Apple-hosted VMs). + "linux-x86_64" + default: + nil // unsupported host + } + } - public var toolchainPath: Path? { - ndkPath?.join("toolchains").join("llvm").join("prebuilt").join(hostTag) - } + public static func findInstallations(host: OperatingSystem, sdkPath: Path, fs: any FSProxy) throws -> [NDK] { + let ndkBasePath = sdkPath.join("ndk") + guard fs.exists(ndkBasePath) else { + return [] + } - public var sysroot: Path? { - toolchainPath?.join("sysroot") - } + let ndks = try fs.listdir(ndkBasePath).map({ try Version($0) }).sorted() + let supportedNdks = ndks.filter { $0 >= minimumNDKVersion } - private var hostTag: String? { - switch host { - case .windows: - // Also works on Windows on ARM via Prism binary translation. - "windows-x86_64" - case .macOS: - // Despite the x86_64 tag in the Darwin name, these are universal binaries including arm64. - "darwin-x86_64" - case .linux: - // Also works on non-x86 archs via binfmt support and qemu (or Rosetta on Apple-hosted VMs). - "linux-x86_64" - default: - nil // unsupported host + // 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. + let discoveredNdks = supportedNdks.isEmpty && !ndks.isEmpty ? ndks : supportedNdks + + return try discoveredNdks.map { ndkVersion in + let ndkPath = ndkBasePath.join(ndkVersion.description) + return try NDK(host: host, path: ndkPath, version: ndkVersion, fs: fs) + } } } diff --git a/Sources/SWBAndroidPlatform/Plugin.swift b/Sources/SWBAndroidPlatform/Plugin.swift index 8b6c8d5a..88fa5f76 100644 --- a/Sources/SWBAndroidPlatform/Plugin.swift +++ b/Sources/SWBAndroidPlatform/Plugin.swift @@ -54,7 +54,7 @@ struct AndroidEnvironmentExtension: EnvironmentExtension { if let latest = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first { return [ "ANDROID_SDK_ROOT": latest.path.str, - "ANDROID_NDK_ROOT": latest.ndkPath?.str, + "ANDROID_NDK_ROOT": latest.ndks.last?.path.str, ].compactMapValues { $0 } } default: @@ -112,10 +112,13 @@ struct AndroidSDKRegistryExtension: SDKRegistryExtension { return [] } - guard let abis = androidSdk.abis, let deploymentTargetRange = androidSdk.deploymentTargetRange else { + guard let androidNdk = androidSdk.latestNDK else { return [] } + let abis = androidNdk.abis + let deploymentTargetRange = androidNdk.deploymentTargetRange + let allPossibleTriples = abis.values.flatMap { abi in (max(deploymentTargetRange.min, abi.min_os_version)...deploymentTargetRange.max).map { deploymentTarget in var triple = abi.llvm_triple @@ -147,7 +150,7 @@ struct AndroidSDKRegistryExtension: SDKRegistryExtension { swiftSettings = [:] } - return [(androidSdk.sysroot ?? .root, androidPlatform, [ + return [(androidNdk.sysroot, androidPlatform, [ "Type": .plString("SDK"), "Version": .plString("0.0.0"), "CanonicalName": .plString("android"), @@ -184,7 +187,7 @@ struct AndroidToolchainRegistryExtension: ToolchainRegistryExtension { let plugin: AndroidPlugin func additionalToolchains(context: any ToolchainRegistryExtensionAdditionalToolchainsContext) async throws -> [Toolchain] { - guard let toolchainPath = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first?.toolchainPath else { + guard let toolchainPath = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first?.latestNDK?.toolchainPath else { return [] } diff --git a/Tests/SWBAndroidPlatformTests/AndroidSDKTests.swift b/Tests/SWBAndroidPlatformTests/AndroidSDKTests.swift new file mode 100644 index 00000000..37329d94 --- /dev/null +++ b/Tests/SWBAndroidPlatformTests/AndroidSDKTests.swift @@ -0,0 +1,361 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +@_spi(Testing) import SWBAndroidPlatform +import SWBTestSupport +import SWBUtil +import Testing + +@Suite +fileprivate struct AndroidSDKTests { + @Test func findInstallations() async throws { + let host = try ProcessInfo.processInfo.hostOperatingSystem() + let installations = try await AndroidSDK.findInstallations(host: host, fs: localFS) + // It's OK if `installations` is an empty set, the host system might have no Android SDK/NDK installed + for installation in installations { + #expect(installation.host == host) + #expect(installation.latestNDK == installation.ndks.last) + } + } + + @Test func abis_r22() async throws { + try await withNDKVersion(version: Version("22.1.7171670")) { host, fs, ndkVersionPath in + let error = #expect(throws: StubError.self) { + try AndroidSDK.NDK.findInstallations(host: host, sdkPath: ndkVersionPath.dirname.dirname, fs: fs) + } + #expect(error?.description == "Android NDK version at path '\(ndkVersionPath.str)' is not supported (r23 or later required)") + } + } + + @Test func abis_r26_3() async throws { + try await withNDKVersion(version: Version("26.3.11579264")) { host, fs, ndkVersionPath in + try await fs.writeFileContents(ndkVersionPath.join("meta").join("abis.json")) { contents in + contents <<< + """ + { + "armeabi-v7a": { + "bitness": 32, + "default": true, + "deprecated": false, + "proc": "armv7-a", + "arch": "arm", + "triple": "arm-linux-androideabi", + "llvm_triple": "armv7-none-linux-androideabi" + }, + "arm64-v8a": { + "bitness": 64, + "default": true, + "deprecated": false, + "proc": "aarch64", + "arch": "arm64", + "triple": "aarch64-linux-android", + "llvm_triple": "aarch64-none-linux-android" + }, + "x86": { + "bitness": 32, + "default": true, + "deprecated": false, + "proc": "i686", + "arch": "x86", + "triple": "i686-linux-android", + "llvm_triple": "i686-none-linux-android" + }, + "x86_64": { + "bitness": 64, + "default": true, + "deprecated": false, + "proc": "x86_64", + "arch": "x86_64", + "triple": "x86_64-linux-android", + "llvm_triple": "x86_64-none-linux-android" + } + } + """ + } + + try await fs.writeFileContents(ndkVersionPath.join("meta").join("platforms.json")) { contents in + contents <<< + """ + { + "min": 21, + "max": 34, + "aliases": { + "20": 19, + "25": 24, + "J": 16, + "J-MR1": 17, + "J-MR2": 18, + "K": 19, + "L": 21, + "L-MR1": 22, + "M": 23, + "N": 24, + "N-MR1": 24, + "O": 26, + "O-MR1": 27, + "P": 28, + "Q": 29, + "R": 30, + "S": 31, + "Sv2": 32, + "Tiramisu": 33, + "UpsideDownCake": 34 + } + } + """ + } + + let installations = try AndroidSDK.NDK.findInstallations(host: host, sdkPath: ndkVersionPath.dirname.dirname, fs: fs) + let installation = try #require(installations.only) + #expect(installation.host == host) + #expect(installation.path == ndkVersionPath) + #expect(try installation.version == Version("26.3.11579264")) + #expect(installation.deploymentTargetRange.min == 21) + #expect(installation.deploymentTargetRange.max == 34) + + #expect(installation.abis.count == 4) + + let armv7 = try #require(installation.abis["armeabi-v7a"]) + #expect(armv7.bitness == .bits32) + #expect(armv7.default == true) + #expect(armv7.deprecated == false) + #expect(armv7.proc == "armv7-a") + #expect(armv7.arch == "arm") + #expect(armv7.triple == "arm-linux-androideabi") + #expect(armv7.llvm_triple.arch == "armv7") + #expect(armv7.llvm_triple.vendor == "none") + #expect(armv7.llvm_triple.system == "linux") + #expect(armv7.llvm_triple.environment == "androideabi") + #expect(armv7.min_os_version == 21) + + let arm64 = try #require(installation.abis["arm64-v8a"]) + #expect(arm64.bitness == .bits64) + #expect(arm64.default == true) + #expect(arm64.deprecated == false) + #expect(arm64.proc == "aarch64") + #expect(arm64.arch == "arm64") + #expect(arm64.triple == "aarch64-linux-android") + #expect(arm64.llvm_triple.arch == "aarch64") + #expect(arm64.llvm_triple.vendor == "none") + #expect(arm64.llvm_triple.system == "linux") + #expect(arm64.llvm_triple.environment == "android") + #expect(arm64.min_os_version == 21) + + let x86 = try #require(installation.abis["x86"]) + #expect(x86.bitness == .bits32) + #expect(x86.default == true) + #expect(x86.deprecated == false) + #expect(x86.proc == "i686") + #expect(x86.arch == "x86") + #expect(x86.triple == "i686-linux-android") + #expect(x86.llvm_triple.arch == "i686") + #expect(x86.llvm_triple.vendor == "none") + #expect(x86.llvm_triple.system == "linux") + #expect(x86.llvm_triple.environment == "android") + #expect(x86.min_os_version == 21) + + let x86_64 = try #require(installation.abis["x86_64"]) + #expect(x86_64.bitness == .bits64) + #expect(x86_64.default == true) + #expect(x86_64.deprecated == false) + #expect(x86_64.proc == "x86_64") + #expect(x86_64.arch == "x86_64") + #expect(x86_64.triple == "x86_64-linux-android") + #expect(x86_64.llvm_triple.arch == "x86_64") + #expect(x86_64.llvm_triple.vendor == "none") + #expect(x86_64.llvm_triple.system == "linux") + #expect(x86_64.llvm_triple.environment == "android") + #expect(x86_64.min_os_version == 21) + } + } + + @Test func abis_r27() async throws { + try await withNDKVersion(version: Version("27.0.11718014")) { host, fs, ndkVersionPath in + try await fs.writeFileContents(ndkVersionPath.join("meta").join("abis.json")) { contents in + contents <<< + """ + { + "armeabi-v7a": { + "bitness": 32, + "default": true, + "deprecated": false, + "proc": "armv7-a", + "arch": "arm", + "triple": "arm-linux-androideabi", + "llvm_triple": "armv7-none-linux-androideabi", + "min_os_version": 21 + }, + "arm64-v8a": { + "bitness": 64, + "default": true, + "deprecated": false, + "proc": "aarch64", + "arch": "arm64", + "triple": "aarch64-linux-android", + "llvm_triple": "aarch64-none-linux-android", + "min_os_version": 21 + }, + "riscv64": { + "bitness": 64, + "default": false, + "deprecated": false, + "proc": "riscv64", + "arch": "riscv64", + "triple": "riscv64-linux-android", + "llvm_triple": "riscv64-none-linux-android", + "min_os_version": 35 + }, + "x86": { + "bitness": 32, + "default": true, + "deprecated": false, + "proc": "i686", + "arch": "x86", + "triple": "i686-linux-android", + "llvm_triple": "i686-none-linux-android", + "min_os_version": 21 + }, + "x86_64": { + "bitness": 64, + "default": true, + "deprecated": false, + "proc": "x86_64", + "arch": "x86_64", + "triple": "x86_64-linux-android", + "llvm_triple": "x86_64-none-linux-android", + "min_os_version": 21 + } + } + """ + } + + try await fs.writeFileContents(ndkVersionPath.join("meta").join("platforms.json")) { contents in + contents <<< + """ + { + "min": 21, + "max": 35, + "aliases": { + "20": 19, + "25": 24, + "J": 16, + "J-MR1": 17, + "J-MR2": 18, + "K": 19, + "L": 21, + "L-MR1": 22, + "M": 23, + "N": 24, + "N-MR1": 24, + "O": 26, + "O-MR1": 27, + "P": 28, + "Q": 29, + "R": 30, + "S": 31, + "Sv2": 32, + "Tiramisu": 33, + "UpsideDownCake": 34, + "VanillaIceCream": 35 + } + } + """ + } + + let installations = try AndroidSDK.NDK.findInstallations(host: host, sdkPath: ndkVersionPath.dirname.dirname, fs: fs) + let installation = try #require(installations.only) + #expect(installation.host == host) + #expect(installation.path == ndkVersionPath) + #expect(try installation.version == Version("27.0.11718014")) + #expect(installation.deploymentTargetRange.min == 21) + #expect(installation.deploymentTargetRange.max == 35) + + #expect(installation.abis.count == 5) + + let armv7 = try #require(installation.abis["armeabi-v7a"]) + #expect(armv7.bitness == .bits32) + #expect(armv7.default == true) + #expect(armv7.deprecated == false) + #expect(armv7.proc == "armv7-a") + #expect(armv7.arch == "arm") + #expect(armv7.triple == "arm-linux-androideabi") + #expect(armv7.llvm_triple.arch == "armv7") + #expect(armv7.llvm_triple.vendor == "none") + #expect(armv7.llvm_triple.system == "linux") + #expect(armv7.llvm_triple.environment == "androideabi") + #expect(armv7.min_os_version == 21) + + let arm64 = try #require(installation.abis["arm64-v8a"]) + #expect(arm64.bitness == .bits64) + #expect(arm64.default == true) + #expect(arm64.deprecated == false) + #expect(arm64.proc == "aarch64") + #expect(arm64.arch == "arm64") + #expect(arm64.triple == "aarch64-linux-android") + #expect(arm64.llvm_triple.arch == "aarch64") + #expect(arm64.llvm_triple.vendor == "none") + #expect(arm64.llvm_triple.system == "linux") + #expect(arm64.llvm_triple.environment == "android") + #expect(arm64.min_os_version == 21) + + let riscv64 = try #require(installation.abis["riscv64"]) + #expect(riscv64.bitness == .bits64) + #expect(riscv64.default == false) + #expect(riscv64.deprecated == false) + #expect(riscv64.proc == "riscv64") + #expect(riscv64.arch == "riscv64") + #expect(riscv64.triple == "riscv64-linux-android") + #expect(riscv64.llvm_triple.arch == "riscv64") + #expect(riscv64.llvm_triple.vendor == "none") + #expect(riscv64.llvm_triple.system == "linux") + #expect(riscv64.llvm_triple.environment == "android") + #expect(riscv64.min_os_version == 35) + + let x86 = try #require(installation.abis["x86"]) + #expect(x86.bitness == .bits32) + #expect(x86.default == true) + #expect(x86.deprecated == false) + #expect(x86.proc == "i686") + #expect(x86.arch == "x86") + #expect(x86.triple == "i686-linux-android") + #expect(x86.llvm_triple.arch == "i686") + #expect(x86.llvm_triple.vendor == "none") + #expect(x86.llvm_triple.system == "linux") + #expect(x86.llvm_triple.environment == "android") + #expect(x86.min_os_version == 21) + + let x86_64 = try #require(installation.abis["x86_64"]) + #expect(x86_64.bitness == .bits64) + #expect(x86_64.default == true) + #expect(x86_64.deprecated == false) + #expect(x86_64.proc == "x86_64") + #expect(x86_64.arch == "x86_64") + #expect(x86_64.triple == "x86_64-linux-android") + #expect(x86_64.llvm_triple.arch == "x86_64") + #expect(x86_64.llvm_triple.vendor == "none") + #expect(x86_64.llvm_triple.system == "linux") + #expect(x86_64.llvm_triple.environment == "android") + #expect(x86_64.min_os_version == 21) + } + } + + private func withNDKVersion(version: Version, _ block: (OperatingSystem, any FSProxy, Path) async throws -> ()) async throws { + let fs = PseudoFS() + let ndkPath = Path.root.join("ndk") + let ndkVersionPath = ndkPath.join(version.description) + try fs.createDirectory(ndkPath, recursive: true) + try fs.createDirectory(ndkVersionPath.join("meta"), recursive: true) + let host = try ProcessInfo.processInfo.hostOperatingSystem() + try await block(host, fs, ndkVersionPath) + } +}