Skip to content

Commit 6d3346f

Browse files
committed
Improve Android SDK/NDK discovery
Now respects the environment variable overrides and looks for the Debian/Ubuntu package location. Closes #495
1 parent 94ed881 commit 6d3346f

File tree

8 files changed

+312
-75
lines changed

8 files changed

+312
-75
lines changed

.github/scripts/linux_pre_build.sh

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
#!/bin/bash
2+
set -e
3+
4+
if command -v apt-get >/dev/null 2>&1 ; then # bookworm, noble, jammy
5+
export DEBIAN_FRONTEND=noninteractive
6+
7+
apt-get update -y
8+
9+
# Build dependencies
10+
apt-get install -y libsqlite3-dev libncurses-dev
11+
12+
# Debug symbols
13+
apt-get install -y libc6-dbg
14+
15+
# Android NDK
16+
dpkg_architecture="$(dpkg --print-architecture)"
17+
if [[ "$dpkg_architecture" == amd64 ]] ; then
18+
eval "$(cat /etc/lsb-release)"
19+
case "$DISTRIB_CODENAME" in
20+
bookworm|jammy)
21+
: # Not available
22+
;;
23+
noble)
24+
apt-get install -y google-android-ndk-r26c-installer
25+
;;
26+
*)
27+
echo "Unknown distribution: $DISTRIB_CODENAME" >&2
28+
exit 1
29+
esac
30+
else
31+
echo "Skipping Android NDK installation on $dpkg_architecture" >&2
32+
fi
33+
elif command -v dnf >/dev/null 2>&1 ; then # rhel-ubi9
34+
dnf update -y
35+
36+
# Build dependencies
37+
dnf install -y sqlite-devel ncurses-devel
38+
39+
# Debug symbols
40+
dnf debuginfo-install -y glibc
41+
elif command -v yum >/dev/null 2>&1 ; then # amazonlinux2
42+
yum update -y
43+
44+
# Build dependencies
45+
yum install -y sqlite-devel ncurses-devel
46+
47+
# Debug symbols
48+
yum install -y yum-utils
49+
debuginfo-install -y glibc
50+
fi

.github/workflows/pull_request.yml

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,7 @@ jobs:
1414
uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main
1515
with:
1616
linux_os_versions: '["noble", "jammy", "rhel-ubi9"]'
17-
linux_pre_build_command: |
18-
if command -v apt-get >/dev/null 2>&1 ; then # bookworm, noble, jammy
19-
apt-get update -y
20-
21-
# Build dependencies
22-
apt-get install -y libsqlite3-dev libncurses-dev
23-
24-
# Debug symbols
25-
apt-get install -y libc6-dbg
26-
elif command -v dnf >/dev/null 2>&1 ; then # rhel-ubi9
27-
dnf update -y
28-
29-
# Build dependencies
30-
dnf install -y sqlite-devel ncurses-devel
31-
32-
# Debug symbols
33-
dnf debuginfo-install -y glibc
34-
elif command -v yum >/dev/null 2>&1 ; then # amazonlinux2
35-
yum update -y
36-
37-
# Build dependencies
38-
yum install -y sqlite-devel ncurses-devel
39-
40-
# Debug symbols
41-
yum install -y yum-utils
42-
debuginfo-install -y glibc
43-
fi
17+
linux_pre_build_command: ./github/scripts/linux_pre_build.sh
4418
linux_build_command: 'swift test --no-parallel'
4519
linux_swift_versions: '["nightly-main", "nightly-6.2"]'
4620
windows_swift_versions: '["nightly-main"]'

Sources/SWBAndroidPlatform/AndroidSDK.swift

Lines changed: 130 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,21 @@ public import Foundation
1616
@_spi(Testing) public struct AndroidSDK: Sendable {
1717
public let host: OperatingSystem
1818
public let path: Path
19+
private let ndkInstallations: NDK.Installations
1920

2021
/// List of NDKs available in this SDK installation, sorted by version number from oldest to newest.
21-
@_spi(Testing) public let ndks: [NDK]
22+
@_spi(Testing) public var ndks: [NDK] {
23+
ndkInstallations.ndks
24+
}
2225

23-
public var latestNDK: NDK? {
24-
ndks.last
26+
public var preferredNDK: NDK? {
27+
ndkInstallations.preferredNDK ?? ndks.last
2528
}
2629

2730
init(host: OperatingSystem, path: Path, fs: any FSProxy) throws {
2831
self.host = host
2932
self.path = path
30-
self.ndks = try NDK.findInstallations(host: host, sdkPath: path, fs: fs)
33+
self.ndkInstallations = try NDK.findInstallations(host: host, sdkPath: path, fs: fs)
3134
}
3235

3336
@_spi(Testing) public struct NDK: Equatable, Sendable {
@@ -39,10 +42,16 @@ public import Foundation
3942
public let abis: [String: ABI]
4043
public let deploymentTargetRange: DeploymentTargetRange
4144

42-
init(host: OperatingSystem, path ndkPath: Path, version: Version, fs: any FSProxy) throws {
45+
@_spi(Testing) public init(host: OperatingSystem, path ndkPath: Path, fs: any FSProxy) throws {
4346
self.host = host
4447
self.path = ndkPath
45-
self.version = version
48+
49+
let propertiesFile = ndkPath.join("source.properties")
50+
guard fs.exists(propertiesFile) else {
51+
throw Error.notAnNDK(ndkPath)
52+
}
53+
54+
self.version = try NDK.Properties(data: Data(fs.read(propertiesFile))).revision
4655

4756
let metaPath = ndkPath.join("meta")
4857

@@ -51,7 +60,7 @@ public import Foundation
5160
}
5261

5362
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)")
63+
throw Error.unsupportedVersion(path: ndkPath, minimumVersion: Self.minimumNDKVersion)
5564
}
5665

5766
self.abis = try JSONDecoder().decode(ABIs.self, from: Data(fs.read(metaPath.join("abis.json"))), configuration: version).abis
@@ -65,6 +74,36 @@ public import Foundation
6574
deploymentTargetRange = DeploymentTargetRange(min: platformsInfo.min, max: platformsInfo.max)
6675
}
6776

77+
public enum Error: Swift.Error, CustomStringConvertible, Sendable {
78+
case notAnNDK(Path)
79+
case unsupportedVersion(path: Path, minimumVersion: Version)
80+
case noSupportedVersions(minimumVersion: Version)
81+
82+
public var description: String {
83+
switch self {
84+
case let .notAnNDK(path):
85+
"Package at path '\(path.str)' is not an Android NDK (no source.properties file)"
86+
case let .unsupportedVersion(path, minimumVersion):
87+
"Android NDK version at path '\(path.str)' is not supported (r\(minimumVersion.description) or later required)"
88+
case let .noSupportedVersions(minimumVersion):
89+
"All installed NDK versions are not supported (r\(minimumVersion.description) or later required)"
90+
}
91+
}
92+
}
93+
94+
struct Properties {
95+
let properties: JavaProperties
96+
let revision: Version
97+
98+
init(data: Data) throws {
99+
properties = try .init(data: data)
100+
guard properties["Pkg.Desc"] == "Android NDK" else {
101+
throw StubError.error("Package is not an Android NDK")
102+
}
103+
revision = try Version(properties["Pkg.BaseRevision"] ?? properties["Pkg.Revision"] ?? "")
104+
}
105+
}
106+
68107
struct ABIs: DecodableWithConfiguration {
69108
let abis: [String: ABI]
70109

@@ -185,27 +224,96 @@ public import Foundation
185224
}
186225
}
187226

188-
public static func findInstallations(host: OperatingSystem, sdkPath: Path, fs: any FSProxy) throws -> [NDK] {
227+
public struct Installations: Sendable {
228+
private let preferredIndex: Int?
229+
public let ndks: [NDK]
230+
231+
init(preferredIndex: Int? = nil, ndks: [NDK]) {
232+
self.preferredIndex = preferredIndex
233+
self.ndks = ndks
234+
}
235+
236+
public var preferredNDK: NDK? {
237+
preferredIndex.map { ndks[$0] } ?? ndks.only
238+
}
239+
}
240+
241+
public static func findInstallations(host: OperatingSystem, sdkPath: Path, fs: any FSProxy) throws -> Installations {
242+
if let overridePath = NDK.environmentOverrideLocation {
243+
return try Installations(ndks: [NDK(host: host, path: overridePath, fs: fs)])
244+
}
245+
189246
let ndkBasePath = sdkPath.join("ndk")
190247
guard fs.exists(ndkBasePath) else {
191-
return []
248+
return Installations(ndks: [])
192249
}
193250

194-
let ndks = try fs.listdir(ndkBasePath).map({ try Version($0) }).sorted()
195-
let supportedNdks = ndks.filter { $0 >= minimumNDKVersion }
251+
var hadUnsupportedVersions: Bool = false
252+
let ndks = try fs.listdir(ndkBasePath).compactMap({ subdir in
253+
do {
254+
return try NDK(host: host, path: ndkBasePath.join(subdir), fs: fs)
255+
} catch Error.notAnNDK(_) {
256+
return nil
257+
} catch Error.unsupportedVersion(_, _) {
258+
hadUnsupportedVersions = true
259+
return nil
260+
}
261+
}).sorted(by: \.version)
196262

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
263+
// If we have some NDKs but all of them are unsupported, provide a more useful error. Otherwise, simply filter out and ignore the unsupported versions.
264+
if ndks.isEmpty && hadUnsupportedVersions {
265+
throw Error.noSupportedVersions(minimumVersion: Self.minimumNDKVersion)
266+
}
199267

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)
268+
// Respect Debian alternatives
269+
let preferredIndex: Int?
270+
if sdkPath == AndroidSDK.defaultDebianLocation {
271+
preferredIndex = try ndks.firstIndex(where: { try $0.path == fs.realpath(Path("/usr/lib/android-ndk")) })
272+
} else {
273+
preferredIndex = nil
203274
}
275+
276+
return Installations(preferredIndex: preferredIndex, ndks: ndks)
204277
}
205278
}
206279

207280
public static func findInstallations(host: OperatingSystem, fs: any FSProxy) async throws -> [AndroidSDK] {
208-
let defaultLocation: Path? = switch host {
281+
var paths: [Path] = []
282+
if let path = AndroidSDK.environmentOverrideLocation {
283+
paths.append(path)
284+
}
285+
if let path = try AndroidSDK.defaultAndroidStudioLocation(host: host) {
286+
paths.append(path)
287+
}
288+
if host == .linux {
289+
paths.append(AndroidSDK.defaultDebianLocation)
290+
}
291+
return try paths.compactMap { path in
292+
guard fs.exists(path) else {
293+
return nil
294+
}
295+
return try AndroidSDK(host: host, path: path, fs: fs)
296+
}
297+
}
298+
}
299+
300+
fileprivate extension AndroidSDK.NDK {
301+
/// The location of the Android NDK based on the `ANDROID_NDK_ROOT` environment variable (falling back to the deprecated but well known `ANDROID_NDK_HOME`).
302+
/// - seealso: [Configuring NDK Path](https://github.com/android/ndk-samples/wiki/Configure-NDK-Path#terminologies)
303+
static var environmentOverrideLocation: Path? {
304+
(getEnvironmentVariable("ANDROID_NDK_ROOT") ?? getEnvironmentVariable("ANDROID_NDK_HOME"))?.nilIfEmpty.map(Path.init)
305+
}
306+
}
307+
308+
fileprivate extension AndroidSDK {
309+
/// The location of the Android SDK based on the `ANDROID_HOME` environment variable (falling back to the deprecated but well known `ANDROID_SDK_ROOT`).
310+
/// - seealso: [Android environment variables](https://developer.android.com/tools/variables)
311+
static var environmentOverrideLocation: Path? {
312+
(getEnvironmentVariable("ANDROID_HOME") ?? getEnvironmentVariable("ANDROID_SDK_ROOT"))?.nilIfEmpty.map(Path.init)
313+
}
314+
315+
static func defaultAndroidStudioLocation(host: OperatingSystem) throws -> Path? {
316+
switch host {
209317
case .windows:
210318
// %LOCALAPPDATA%\Android\Sdk
211319
try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("Android").appendingPathComponent("Sdk").filePath
@@ -218,11 +326,11 @@ public import Foundation
218326
default:
219327
nil
220328
}
329+
}
221330

222-
if let path = defaultLocation, fs.exists(path) {
223-
return try [AndroidSDK(host: host, path: path, fs: fs)]
224-
}
225-
226-
return []
331+
/// Location of the Android NDK installed by the `google-android-ndk-*-installer` family of packages available in Debian 13 "Trixie" and Ubuntu 24.04 "Noble".
332+
/// These packages are available in non-free / multiverse and multiple versions can be installed simultaneously.
333+
static var defaultDebianLocation: Path {
334+
Path("/usr/lib/android-sdk")
227335
}
228336
}

Sources/SWBAndroidPlatform/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ See http://swift.org/CONTRIBUTORS.txt for Swift project authors
1010

1111
add_library(SWBAndroidPlatform
1212
AndroidSDK.swift
13+
JavaProperties.swift
1314
Plugin.swift)
1415
SwiftBuild_Bundle(MODULE SWBAndroidPlatform FILES
1516
Specs/Android.xcspec)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
internal import SWBUtil
15+
16+
/// A simple representation of a Java properties file.
17+
///
18+
/// See `java.util.Properties` for a description of the file format. This parser is a simplified version that doesn't handle line continuations, etc., because our use case is narrow.
19+
struct JavaProperties {
20+
private let properties: [String: String]
21+
22+
init(data: Data) throws {
23+
properties = Dictionary(uniqueKeysWithValues: String(decoding: data, as: UTF8.self).split(whereSeparator: { $0.isNewline }).map(String.init).map {
24+
let (key, value) = $0.split("=")
25+
return (key.trimmingCharacters(in: .whitespaces), value.trimmingCharacters(in: .whitespaces))
26+
})
27+
}
28+
29+
subscript(_ propertyName: String) -> String? {
30+
properties[propertyName]
31+
}
32+
}

Sources/SWBAndroidPlatform/Plugin.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,13 @@ struct AndroidEnvironmentExtension: EnvironmentExtension {
5252
switch context.hostOperatingSystem {
5353
case .windows, .macOS, .linux:
5454
if let latest = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first {
55+
let sdkPath = latest.path.str
56+
let ndkPath = latest.preferredNDK?.path.str
5557
return [
56-
"ANDROID_SDK_ROOT": latest.path.str,
57-
"ANDROID_NDK_ROOT": latest.ndks.last?.path.str,
58+
"ANDROID_HOME": sdkPath,
59+
"ANDROID_SDK_ROOT": sdkPath,
60+
"ANDROID_NDK_ROOT": ndkPath,
61+
"ANDROID_NDK_HOME": ndkPath,
5862
].compactMapValues { $0 }
5963
}
6064
default:
@@ -112,7 +116,7 @@ struct AndroidSDKRegistryExtension: SDKRegistryExtension {
112116
return []
113117
}
114118

115-
guard let androidNdk = androidSdk.latestNDK else {
119+
guard let androidNdk = androidSdk.preferredNDK else {
116120
return []
117121
}
118122

@@ -187,7 +191,7 @@ struct AndroidToolchainRegistryExtension: ToolchainRegistryExtension {
187191
let plugin: AndroidPlugin
188192

189193
func additionalToolchains(context: any ToolchainRegistryExtensionAdditionalToolchainsContext) async throws -> [Toolchain] {
190-
guard let toolchainPath = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first?.latestNDK?.toolchainPath else {
194+
guard let toolchainPath = try? await plugin.cachedAndroidSDKInstallations(host: context.hostOperatingSystem).first?.preferredNDK?.toolchainPath else {
191195
return []
192196
}
193197

Sources/SWBUtil/FSProxy.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -879,9 +879,13 @@ public class PseudoFS: FSProxy, @unchecked Sendable {
879879

880880
public func realpath(_ path: Path) throws -> Path {
881881
// TODO: Update this to actually return the link target when we support
882-
// symlinks; for now it just returns the input, which seems reasonably
883-
// correct.
884-
return path
882+
// symlinks; for now it just returns the input (or the link target if it's a symlink),
883+
// which seems reasonably correct for simple cases.
884+
do {
885+
return try readlink(path)
886+
} catch {
887+
return path
888+
}
885889
}
886890

887891
public func readlink(_ path: Path) throws -> Path {

0 commit comments

Comments
 (0)