@@ -15,43 +15,54 @@ public import Foundation
1515
1616@_spi ( Testing) public struct AndroidSDK : Sendable {
1717 public let host : OperatingSystem
18- public let path : Path
18+ public let path : AbsolutePath
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
27- init ( host: OperatingSystem , path: Path , fs: any FSProxy ) throws {
30+ init ( host: OperatingSystem , path: AbsolutePath , 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 {
3437 public static let minimumNDKVersion = Version ( 23 )
3538
3639 public let host : OperatingSystem
37- public let path : Path
40+ public let path : AbsolutePath
3841 public let version : Version
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: AbsolutePath , fs: any FSProxy ) throws {
4346 self . host = host
4447 self . path = ndkPath
45- self . version = version
48+ self . toolchainPath = try AbsolutePath ( validating: path. path. join ( " toolchains " ) . join ( " llvm " ) . join ( " prebuilt " ) . join ( Self . hostTag ( host) ) )
49+ self . sysroot = try AbsolutePath ( validating: toolchainPath. path. join ( " sysroot " ) )
50+
51+ let propertiesFile = ndkPath. path. join ( " source.properties " )
52+ guard fs. exists ( propertiesFile) else {
53+ throw Error . notAnNDK ( ndkPath)
54+ }
4655
47- let metaPath = ndkPath. join ( " meta " )
56+ self . version = try NDK . Properties ( data: Data ( fs. read ( propertiesFile) ) ) . revision
57+
58+ let metaPath = ndkPath. path. join ( " meta " )
4859
4960 guard #available( macOS 14 , * ) else {
5061 throw StubError . error ( " Unsupported macOS version " )
5162 }
5263
5364 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) " )
65+ throw Error . unsupportedVersion ( path: ndkPath, minimumVersion : Self . minimumNDKVersion)
5566 }
5667
5768 self . abis = try JSONDecoder ( ) . decode ( ABIs . self, from: Data ( fs. read ( metaPath. join ( " abis.json " ) ) ) , configuration: version) . abis
@@ -65,6 +76,36 @@ public import Foundation
6576 deploymentTargetRange = DeploymentTargetRange ( min: platformsInfo. min, max: platformsInfo. max)
6677 }
6778
79+ public enum Error : Swift . Error , CustomStringConvertible , Sendable {
80+ case notAnNDK( AbsolutePath )
81+ case unsupportedVersion( path: AbsolutePath , minimumVersion: Version )
82+ case noSupportedVersions( minimumVersion: Version )
83+
84+ public var description : String {
85+ switch self {
86+ case let . notAnNDK( path) :
87+ " Package at path ' \( path. path. str) ' is not an Android NDK (no source.properties file) "
88+ case let . unsupportedVersion( path, minimumVersion) :
89+ " Android NDK version at path ' \( path. path. str) ' is not supported (r \( minimumVersion. description) or later required) "
90+ case let . noSupportedVersions( minimumVersion) :
91+ " All installed NDK versions are not supported (r \( minimumVersion. description) or later required) "
92+ }
93+ }
94+ }
95+
96+ struct Properties {
97+ let properties : JavaProperties
98+ let revision : Version
99+
100+ init ( data: Data ) throws {
101+ properties = try . init( data: data)
102+ guard properties [ " Pkg.Desc " ] == " Android NDK " else {
103+ throw StubError . error ( " Package is not an Android NDK " )
104+ }
105+ revision = try Version ( properties [ " Pkg.BaseRevision " ] ?? properties [ " Pkg.Revision " ] ?? " " )
106+ }
107+ }
108+
68109 struct ABIs : DecodableWithConfiguration {
69110 let abis : [ String : ABI ]
70111
@@ -161,15 +202,10 @@ public import Foundation
161202 public let max : Int
162203 }
163204
164- public var toolchainPath : Path {
165- path. join ( " toolchains " ) . join ( " llvm " ) . join ( " prebuilt " ) . join ( hostTag)
166- }
167-
168- public var sysroot : Path {
169- toolchainPath. join ( " sysroot " )
170- }
205+ public let toolchainPath : AbsolutePath
206+ public let sysroot : AbsolutePath
171207
172- private var hostTag : String ? {
208+ private static func hostTag( _ host : OperatingSystem ) -> String ? {
173209 switch host {
174210 case . windows:
175211 // Also works on Windows on ARM via Prism binary translation.
@@ -185,44 +221,119 @@ public import Foundation
185221 }
186222 }
187223
188- public static func findInstallations( host: OperatingSystem , sdkPath: Path , fs: any FSProxy ) throws -> [ NDK ] {
189- let ndkBasePath = sdkPath. join ( " ndk " )
224+ public struct Installations : Sendable {
225+ private let preferredIndex : Int ?
226+ public let ndks : [ NDK ]
227+
228+ init ( preferredIndex: Int ? = nil , ndks: [ NDK ] ) {
229+ self . preferredIndex = preferredIndex
230+ self . ndks = ndks
231+ }
232+
233+ public var preferredNDK : NDK ? {
234+ preferredIndex. map { ndks [ $0] } ?? ndks. only
235+ }
236+ }
237+
238+ public static func findInstallations( host: OperatingSystem , sdkPath: AbsolutePath , fs: any FSProxy ) throws -> Installations {
239+ if let overridePath = NDK . environmentOverrideLocation {
240+ return try Installations ( ndks: [ NDK ( host: host, path: overridePath, fs: fs) ] )
241+ }
242+
243+ let ndkBasePath = sdkPath. path. join ( " ndk " )
190244 guard fs. exists ( ndkBasePath) else {
191- return [ ]
245+ return Installations ( ndks : [ ] )
192246 }
193247
194- let ndks = try fs. listdir ( ndkBasePath) . map ( { try Version ( $0) } ) . sorted ( )
195- let supportedNdks = ndks. filter { $0 >= minimumNDKVersion }
248+ var hadUnsupportedVersions : Bool = false
249+ let ndks = try fs. listdir ( ndkBasePath) . compactMap ( { subdir in
250+ do {
251+ return try NDK ( host: host, path: AbsolutePath ( validating: ndkBasePath. join ( subdir) ) , fs: fs)
252+ } catch Error . notAnNDK( _) {
253+ return nil
254+ } catch Error . unsupportedVersion( _, _) {
255+ hadUnsupportedVersions = true
256+ return nil
257+ }
258+ } ) . sorted ( by: \. version)
196259
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
260+ // 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.
261+ if ndks. isEmpty && hadUnsupportedVersions {
262+ throw Error . noSupportedVersions ( minimumVersion: Self . minimumNDKVersion)
263+ }
199264
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)
265+ // Respect Debian alternatives
266+ let preferredIndex : Int ?
267+ if sdkPath == AndroidSDK . defaultDebianLocation, let ndkLinkPath = AndroidSDK . NDK. defaultDebianLocation {
268+ preferredIndex = try ndks. firstIndex ( where: { try $0. path. path == fs. realpath ( ndkLinkPath. path) } )
269+ } else {
270+ preferredIndex = nil
203271 }
272+
273+ return Installations ( preferredIndex: preferredIndex, ndks: ndks)
204274 }
205275 }
206276
207277 public static func findInstallations( host: OperatingSystem , fs: any FSProxy ) async throws -> [ AndroidSDK ] {
208- let defaultLocation : Path ? = switch host {
278+ var paths : [ AbsolutePath ] = [ ]
279+ if let path = AndroidSDK . environmentOverrideLocation {
280+ paths. append ( path)
281+ }
282+ if let path = try AndroidSDK . defaultAndroidStudioLocation ( host: host) {
283+ paths. append ( path)
284+ }
285+ if let path = AndroidSDK . defaultDebianLocation, host == . linux {
286+ paths. append ( path)
287+ }
288+ return try paths. compactMap { path in
289+ guard fs. exists ( path. path) else {
290+ return nil
291+ }
292+ return try AndroidSDK ( host: host, path: path, fs: fs)
293+ }
294+ }
295+ }
296+
297+ fileprivate extension AndroidSDK . NDK {
298+ /// 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`).
299+ /// - seealso: [Configuring NDK Path](https://github.com/android/ndk-samples/wiki/Configure-NDK-Path#terminologies)
300+ static var environmentOverrideLocation : AbsolutePath ? {
301+ ( getEnvironmentVariable ( " ANDROID_NDK_ROOT " ) ?? getEnvironmentVariable ( " ANDROID_NDK_HOME " ) ) ? . nilIfEmpty. map { AbsolutePath ( $0) } ?? nil
302+ }
303+
304+ /// 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".
305+ /// These packages are available in non-free / multiverse and multiple versions can be installed simultaneously.
306+ static var defaultDebianLocation : AbsolutePath ? {
307+ AbsolutePath ( " /usr/lib/android-ndk " )
308+ }
309+ }
310+
311+ fileprivate extension AndroidSDK {
312+ /// The location of the Android SDK based on the `ANDROID_HOME` environment variable (falling back to the deprecated but well known `ANDROID_SDK_ROOT`).
313+ /// - seealso: [Android environment variables](https://developer.android.com/tools/variables)
314+ static var environmentOverrideLocation : AbsolutePath ? {
315+ ( getEnvironmentVariable ( " ANDROID_HOME " ) ?? getEnvironmentVariable ( " ANDROID_SDK_ROOT " ) ) ? . nilIfEmpty. map { AbsolutePath ( $0) } ?? nil
316+ }
317+
318+ static func defaultAndroidStudioLocation( host: OperatingSystem ) throws -> AbsolutePath ? {
319+ switch host {
209320 case . windows:
210321 // %LOCALAPPDATA%\Android\Sdk
211- try FileManager . default. url ( for: . applicationSupportDirectory, in: . userDomainMask, appropriateFor: nil , create: false ) . appendingPathComponent ( " Android " ) . appendingPathComponent ( " Sdk " ) . filePath
322+ try FileManager . default. url ( for: . applicationSupportDirectory, in: . userDomainMask, appropriateFor: nil , create: false ) . appendingPathComponent ( " Android " ) . appendingPathComponent ( " Sdk " ) . absoluteFilePath
212323 case . macOS:
213324 // ~/Library/Android/sdk
214- try FileManager . default. url ( for: . libraryDirectory, in: . userDomainMask, appropriateFor: nil , create: false ) . appendingPathComponent ( " Android " ) . appendingPathComponent ( " sdk " ) . filePath
325+ try FileManager . default. url ( for: . libraryDirectory, in: . userDomainMask, appropriateFor: nil , create: false ) . appendingPathComponent ( " Android " ) . appendingPathComponent ( " sdk " ) . absoluteFilePath
215326 case . linux:
216327 // ~/Android/Sdk
217- Path . homeDirectory. join ( " Android " ) . join ( " Sdk " )
328+ try AbsolutePath ( validating : Path . homeDirectory. join ( " Android " ) . join ( " Sdk " ) )
218329 default :
219330 nil
220331 }
332+ }
221333
222- if let path = defaultLocation, fs. exists ( path) {
223- return try [ AndroidSDK ( host: host, path: path, fs: fs) ]
224- }
225-
226- return [ ]
334+ /// Location of the Android SDK installed by the `google-*` family of packages available in Debian 13 "Trixie" and Ubuntu 24.04 "Noble".
335+ /// These packages are available in non-free / multiverse and multiple versions can be installed simultaneously.
336+ static var defaultDebianLocation : AbsolutePath ? {
337+ AbsolutePath ( " /usr/lib/android-sdk " )
227338 }
228339}
0 commit comments