Skip to content

Commit 24c72d1

Browse files
committed
Add network and embedInCode resource test cases
1 parent 04415c0 commit 24c72d1

File tree

5 files changed

+139
-4
lines changed

5 files changed

+139
-4
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,6 @@ let package = Package(
7272
]),
7373
.testTarget(name: "AndroidNativeTests", dependencies: [
7474
"AndroidNative",
75-
]),
75+
], resources: [.embedInCode("Resources/sample_resource.txt")]),
7676
]
7777
)

README.md

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# Android Native
22

33
This package provides a Swift interface to various
4-
Android [NDK APIs](https://developer.android.com/ndk/reference).
4+
Android [NDK APIs](https://developer.android.com/ndk/reference)
5+
and utilities to integrate Swift Foundation with the Android environment.
56

67
## Requirements
78

8-
- Swift 5.9
9-
- [Swift Android SDK](https://github.com/finagolfin/swift-android-sdk)
9+
- Swift 6
10+
- [Swift Android Toolchain and SDK](https://github.com/skiptools/swift-android-toolchain)
1011

1112
## Installation
1213

@@ -192,6 +193,28 @@ Add the `AndroidLooper` module as a conditional dependency for any targets that
192193
])
193194
```
194195

196+
# AndroidBootstrap
197+
198+
The [AndroidBootstrap] class is part of the top-level [AndroidNative] module, and provides
199+
some conveniences for configuring Android to work with other Foundation types.
200+
201+
## Networking
202+
203+
Foundation's `URLSession` cannot load "https" URLs out of the box on Android because it
204+
doesn't know where to look to find the local certificate authority files. In order to
205+
set up `URLSession` properly, first call `AndroidBootstrap.setupCACerts()` one time
206+
in order to initialize the certificate bundle.
207+
208+
For example:
209+
210+
```swift
211+
import FoundationNetworking
212+
import AndroidNative
213+
214+
try AndroidBootstrap.setupCACerts() // needed in order to use https
215+
let url = URL(string: "https://httpbin.org/get?x=1")!
216+
let (data, response) = try await URLSession.shared.data(from: url)
217+
```
195218

196219
# License
197220

Sources/AndroidNative/AndroidNative.swift

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,83 @@
55
@_exported import AndroidLooper
66
@_exported import AndroidChoreographer
77
@_exported import AndroidContext
8+
9+
#if canImport(Android)
10+
import Android
11+
import Foundation
12+
13+
/// Utilities for setting up Android compatibility with Foundation
14+
public class AndroidBootstrap {
15+
/// Collects all the certificate files from the Android certificate store and writes them to a single `cacerts.pem` file that can be used by libcurl,
16+
/// which is communicated through the `URLSessionCertificateAuthorityInfoFile` environment property
17+
///
18+
/// See https://android.googlesource.com/platform/frameworks/base/+/8b192b19f264a8829eac2cfaf0b73f6fc188d933%5E%21/#F0
19+
/// See https://github.com/apple/swift-nio-ssl/blob/d1088ebe0789d9eea231b40741831f37ab654b61/Sources/NIOSSL/AndroidCABundle.swift#L30
20+
@available(macOS 13.0, iOS 16.0, *)
21+
public static func setupCACerts(force: Bool = false, fromCertficateFolders certsFolders: [String] = ["/system/etc/security/cacerts", "/apex/com.android.conscrypt/cacerts"]) throws {
22+
// if someone else has already set URLSessionCertificateAuthorityInfoFile then do not override unless forced
23+
if !force && getenv("URLSessionCertificateAuthorityInfoFile") != nil {
24+
return
25+
}
26+
27+
//let cacheFolder = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) // file:////.cache/ (unwritable)
28+
let cacheFolder = URL.temporaryDirectory
29+
//logger.debug("setupCACerts: \(cacheFolder)")
30+
let generatedCacertsURL = cacheFolder.appendingPathComponent("cacerts-aggregate.pem")
31+
//logger.debug("setupCACerts: generatedCacertsURL=\(generatedCacertsURL)")
32+
33+
let contents = try FileManager.default.contentsOfDirectory(at: cacheFolder, includingPropertiesForKeys: nil)
34+
//logger.debug("setupCACerts: cacheFolder=\(cacheFolder) contents=\(contents)")
35+
36+
// clear any previous generated certificates file that may have been created by this app
37+
if FileManager.default.fileExists(atPath: generatedCacertsURL.path) {
38+
try FileManager.default.removeItem(atPath: generatedCacertsURL.path)
39+
}
40+
41+
let created = FileManager.default.createFile(atPath: generatedCacertsURL.path, contents: nil)
42+
//logger.debug("setupCACerts: created file: \(created): \(generatedCacertsURL.path)")
43+
44+
let fs = try FileHandle(forWritingTo: generatedCacertsURL)
45+
defer { try? fs.close() }
46+
47+
// write a header
48+
fs.write("""
49+
## Bundle of CA Root Certificates
50+
## Auto-generated on \(Date())
51+
## by aggregating certificates from: \(certsFolders)
52+
53+
""".data(using: .utf8)!)
54+
55+
// Go through each folder and load each certificate file (ending with ".0"),
56+
// and smash them together into a single aggreagate file tha curl can load.
57+
// The .0 files will contain some extra metadata, but libcurl only cares about the
58+
// -----BEGIN CERTIFICATE----- and -----END CERTIFICATE----- sections,
59+
// so we can naïvely concatenate them all and libcurl will understand the bundle.
60+
for certsFolder in certsFolders {
61+
let certsFolderURL = URL(fileURLWithPath: certsFolder)
62+
if (try? certsFolderURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) != true { continue }
63+
let certURLs = try FileManager.default.contentsOfDirectory(at: certsFolderURL, includingPropertiesForKeys: [.isRegularFileKey, .isReadableKey])
64+
for certURL in certURLs {
65+
//logger.debug("setupCACerts: certURL=\(certURL)")
66+
// certificate files have names like "53a1b57a.0"
67+
if certURL.pathExtension != "0" { continue }
68+
do {
69+
if try certURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == false { continue }
70+
if try certURL.resourceValues(forKeys: [.isReadableKey]).isReadable == false { continue }
71+
try fs.write(contentsOf: try Data(contentsOf: certURL))
72+
} catch {
73+
//logger.warning("setupCACerts: error reading certificate file \(certURL.path): \(error)")
74+
continue
75+
}
76+
}
77+
}
78+
79+
80+
//setenv("URLSessionCertificateAuthorityInfoFile", "INSECURE_SSL_NO_VERIFY", 1) // disables all certificate verification
81+
//setenv("URLSessionCertificateAuthorityInfoFile", "/system/etc/security/cacerts/", 1) // doesn't work for directories
82+
setenv("URLSessionCertificateAuthorityInfoFile", generatedCacertsURL.path, 1)
83+
//logger.debug("setupCACerts: set URLSessionCertificateAuthorityInfoFile=\(generatedCacertsURL.path)")
84+
}
85+
}
86+
#endif
87+

Tests/AndroidNativeTests/AndroidNativeTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,38 @@
11
import XCTest
22
import AndroidNative
3+
#if canImport(FoundationNetworking)
4+
import FoundationEssentials
5+
import FoundationNetworking
6+
#else
7+
import Foundation
8+
#endif
39

410
class AndroidNativeTests : XCTestCase {
11+
public func testNetwork() async throws {
12+
struct HTTPGetResponse : Decodable {
13+
var args: [String: String]
14+
var headers: [String: String]
15+
var origin: String?
16+
var url: String?
17+
}
18+
try AndroidBootstrap.setupCACerts() // needed in order to use https
19+
let url = URL(string: "https://httpbin.org/get?x=1")!
20+
let (data, response) = try await URLSession.shared.data(from: url)
21+
let statusCode = (response as? HTTPURLResponse)?.statusCode
22+
if statusCode != 200 {
23+
// do not fail the test just because httpbin.org is unavailable
24+
throw XCTSkip("tolerating bad status code: \(statusCode ?? 0) for url: \(url.absoluteString)")
25+
}
26+
XCTAssertEqual(200, statusCode)
27+
let get = try JSONDecoder().decode(HTTPGetResponse.self, from: data)
28+
XCTAssertEqual(get.url, url.absoluteString)
29+
XCTAssertEqual(get.args["x"], "1")
30+
}
31+
32+
public func testEmbedInCodeResource() async throws {
33+
XCTAssertEqual("Hello Android!\n", String(data: Data(PackageResources.sample_resource_txt), encoding: .utf8) ?? "")
34+
}
35+
536
@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
637
public func testMainActor() async {
738
let actorDemo = await MainActorDemo()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Hello Android!

0 commit comments

Comments
 (0)