diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f73d1b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + swift-build: + name: Build Swift Package + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Swift version + run: swift --version + - name: Build + run: swift build + + swift-test: + name: Unit Tests (macOS) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Test + run: swift test + + ios-playground: + name: Build iOS Playground App + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Build Fueling.swiftpm for iOS Simulator + working-directory: Fueling.swiftpm + run: | + xcodebuild build \ + -scheme Fueling \ + -destination 'generic/platform=iOS Simulator' \ + -skipMacroValidation + + android: + name: Build Android App + runs-on: ubuntu-latest + env: + SWIFT_VERSION: "6.3.3" + NDK_VERSION: "27.2.12479018" + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install Android NDK + run: sdkmanager --install "ndk;${NDK_VERSION}" + + - name: Install Swift toolchain + uses: swift-actions/setup-swift@v2 + with: + swift-version: ${{ env.SWIFT_VERSION }} + + - name: Install Android Swift SDK + run: | + curl -O "https://download.swift.org/swift-${SWIFT_VERSION}-release/android-sdk/swift-${SWIFT_VERSION}-RELEASE/swift-${SWIFT_VERSION}-RELEASE_android.artifactbundle.tar.gz" + swift sdk install "swift-${SWIFT_VERSION}-RELEASE_android.artifactbundle.tar.gz" + + bundle_dir=$(dirname "$(find "$HOME" -path '*swift-sdks*swift-android/scripts/setup-android-sdk.sh' 2>/dev/null | head -n1)") + ANDROID_NDK_HOME="$ANDROID_SDK_ROOT/ndk/${NDK_VERSION}" bash "$bundle_dir/setup-android-sdk.sh" + + - name: Configure local.properties + working-directory: Android + run: echo "sdk.dir=$ANDROID_SDK_ROOT" > local.properties + + - name: Build Android app + working-directory: Android + run: ./gradlew :app:assembleDebug --no-daemon diff --git a/Android/Package.resolved b/Android/Package.resolved index b8de010..bc3351e 100644 --- a/Android/Package.resolved +++ b/Android/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "1231a04ba97f6821d07f530c4da09ebf259d8ba9c85c6f0e981eb360d28a5f42", + "originHash" : "1fca016c329f256f1868e21dfe02c9c57b7f1cd5a487c5bdb36e968c8e5088e4", "pins" : [ { "identity" : "coremodel", @@ -16,7 +16,7 @@ "location" : "https://github.com/PureSwift/CoreModel-SQLite", "state" : { "branch" : "master", - "revision" : "e96bebbb230646ffc06dda584faef5f3e9d49681" + "revision" : "07b8c3ab7e7e7f687b1fb4b56c66689289fa960f" } }, { diff --git a/Android/Package.swift b/Android/Package.swift index 5076016..ae98afc 100644 --- a/Android/Package.swift +++ b/Android/Package.swift @@ -36,6 +36,15 @@ let package = Package( .target( name: "FuelingAndroid", dependencies: [ + // NOTE: no explicit FuelingAPI/HTTPTypes product dependencies — + // both are statically embedded in the dynamic FuelingModel + // product, and adding them as products creates a SECOND copy of + // the `HTTPClient` protocol descriptor (`libFuelingAPI.so` + + // the copy inside `libFuelingModel.so`), which breaks runtime + // conformance lookup for `AndroidHTTPClient: HTTPClient` + // (null witness table → SIGSEGV when copying the + // `any LocationService` existential). Their modules remain + // importable transitively through the build graph. .product(name: "FuelingModel", package: "FuelingApp"), .product(name: "CoreFueling", package: "FuelingApp"), .product( diff --git a/Android/README.md b/Android/README.md index 3260d82..cf1b7c1 100644 --- a/Android/README.md +++ b/Android/README.md @@ -19,30 +19,60 @@ over JNI, generated by [swift-java](https://github.com/swiftlang/swift-java)'s ## Networking -`FuelingSession` fetches from a real server over `URLSession` -(`HTTPTypesFoundation`'s `FoundationNetworking`), verified working end to end -against a local test server on a real emulator. The base URL is injected at -Gradle build time from the `FUELING_SERVER_URL` environment variable (an -installed app has no shell environment of its own to read at runtime) — -baked into `BuildConfig.FUELING_SERVER_URL` (see `app/build.gradle.kts`), -defaulting to `http://localhost:8080`, and passed into -`FuelingSession.init(documentsPath:serverURL:)`. Pass a blank/unparsable -string instead to run fully offline — `FuelingSession` then seeds the local -SQLite cache with sample locations built directly from `CoreFueling.Location`. - -Two Android-specific pieces this needed that are easy to miss: - -- **`libFoundationNetworking.so` must be staged** into `jniLibs` (see - `fueling-jni/build.gradle.kts`'s `stageJniLibs` task) — omitting it doesn't - just break the network call, it fails the *entire* native library load at - app startup (`UnsatisfiedLinkError: library "libFoundationNetworking.so" - not found`). +The HTTP transport is implemented **in Kotlin** (`HttpURLConnectionTransport` +in `app/`, using the platform's own `HttpURLConnection`) and injected into the +shared Swift networking stack through a swift-java JNI *callback* protocol +(`AndroidHTTPTransport`, enabled by `enableJavaCallbacks` in +`swift-java.config`) — Kotlin implements the generated Java interface, and the +`AndroidHTTPClient` Swift adapter bridges it into `FuelingAPI`'s `HTTPClient` +protocol. This means `FoundationNetworking` (and its transitive +`lib_FoundationICU.so` requirement) is **not linked at all** — verified with +`llvm-readobj --needed-libs` — and `libFoundationNetworking.so` is excluded +from `jniLibs`. + +The callback protocol is deliberately flat (only `String`, `Int32`, arrays, +and untyped `throws` cross the JNI boundary): swift-java's callback bridging +supports neither `associatedtype`, typed throws, nor `async`, so the real +`HTTPClient` conformance (`async throws`) lives in the hand-written +`AndroidHTTPClient` adapter, which hops the blocking JNI upcall onto a +dedicated serial `DispatchQueue` (never Swift Concurrency's cooperative pool). + +Two hard-won structural constraints to preserve: + +- **Do not add `FuelingAPI` (or `HTTPTypes`) as explicit product dependencies + of the `FuelingAndroid` target.** Both are statically embedded inside the + dynamic `FuelingModel` product; adding them again as products creates a + second copy of the `HTTPClient` protocol descriptor in its own + `libFuelingAPI.so`, which silently breaks runtime conformance lookup for + `AndroidHTTPClient: HTTPClient` (null witness table — manifests as a + SIGSEGV copying the `any LocationService` existential, or the service + silently becoming `nil`). The modules stay importable transitively. +- **`AndroidHTTPClient.data(for:)` must use untyped `throws`**, matching the + `URLSession` conformance's shape — see the comment in the source. + +The base URL is injected at Gradle build time from the `FUELING_SERVER_URL` +environment variable (an installed app has no shell environment of its own to +read at runtime) — baked into `BuildConfig.FUELING_SERVER_URL` (see +`app/build.gradle.kts`), defaulting to `http://localhost:8080`, and passed +into `FuelingSession.init(documentsPath:serverURL:transport:)` along with the +Kotlin transport. Pass a blank/unparsable URL instead to run fully offline — +`FuelingSession` then seeds the local SQLite cache with sample locations +built directly from `CoreFueling.Location`. + +Android-specific requirements that are easy to miss: + - **Cleartext (`http://`) traffic is blocked by default** since API 28. A `network_security_config.xml` permits it narrowly, only for `localhost`/`10.0.2.2`/`127.0.0.1` (the default dev-server case) — real deployments should use HTTPS, which needs no exception. The `android.permission.INTERNET` manifest permission is also required, and is easy to forget entirely. +- **`enableJavaCallbacks` adds a build-time requirement**: `swift build` + shells out to swift-java's own internal Gradle sub-build (compiling its + `SwiftKitCore` Java module), which needs a JDK (see the `jdk25Home` + property in `fueling-jni/build.gradle.kts`, defaulting to Homebrew's + `openjdk@25`) and a one-time network fetch of that sub-build's Gradle + distribution after any `.build` wipe. `10.0.2.2` is the Android emulator's alias for the host machine's own `localhost` — override `FUELING_SERVER_URL`/`fuelingServerUrl` to that host diff --git a/Android/Sources/FuelingAndroid/AndroidHTTPClient.swift b/Android/Sources/FuelingAndroid/AndroidHTTPClient.swift new file mode 100644 index 0000000..55e3bdf --- /dev/null +++ b/Android/Sources/FuelingAndroid/AndroidHTTPClient.swift @@ -0,0 +1,98 @@ +// +// AndroidHTTPClient.swift +// FuelingAndroid +// +// Adapts a Kotlin/Java-backed `AndroidHTTPTransport` callback into the +// `HTTPClient` protocol that `APILocationService` expects. +// +// Deliberately `internal`, not `public`: pure implementation detail, +// constructed only inside `FuelingSession.init`, so jextract never generates +// standalone JNI bindings for it (mirroring the screen adapters' pattern). +// + +#if canImport(FoundationEssentials) +import FoundationEssentials +#elseif canImport(Foundation) +import Foundation +#endif +import Dispatch +import HTTPTypes +import CoreFueling +import FuelingAPI + +/// Bridges the synchronous, blocking ``AndroidHTTPTransport`` JNI callback +/// into `HTTPClient`'s `async throws` requirement. +final class AndroidHTTPClient: HTTPClient, @unchecked Sendable { + + /// Kotlin/Java-backed transport. JNI callback objects are plain Java + /// references — thread-confinement is provided by `queue`, not the type. + private let transport: any AndroidHTTPTransport + + /// Serial queue the blocking JNI upcall runs on. A dedicated dispatch + /// queue (a real OS thread) rather than the caller's Swift Concurrency + /// cooperative pool, which must never be blocked on synchronous I/O. + /// Serial execution also makes the transport's stateful + /// `send` → `response*()` sequence atomic per request. + private let queue: DispatchQueue + + init(transport: any AndroidHTTPTransport) { + self.transport = transport + self.queue = DispatchQueue(label: "com.fuelingapp.AndroidHTTPClient") + } + + // Untyped `throws` (`HTTPError == any Error`), matching the shape of the + // `URLSession` conformance: a typed-`throws(FuelingError)` witness here + // miscompiled on the Android toolchain — the resulting + // `APILocationService` existential box crashed + // (SIGSEGV) or silently became `nil` when copied. + func data(for request: HTTPRequest) async throws -> (Data, HTTPResponse) { + try await withCheckedThrowingContinuation { continuation in + queue.async { [transport] in + do { + continuation.resume(returning: try Self.perform(request, transport: transport)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private static func perform( + _ request: HTTPRequest, + transport: any AndroidHTTPTransport + ) throws -> (Data, HTTPResponse) { + guard let scheme = request.scheme, + let authority = request.authority + else { + throw FuelingError.error("Invalid request: missing scheme or authority") + } + let url = scheme + "://" + authority + (request.path ?? "/") + var headerNames = [String]() + var headerValues = [String]() + headerNames.reserveCapacity(request.headerFields.count) + headerValues.reserveCapacity(request.headerFields.count) + for field in request.headerFields { + headerNames.append(field.name.rawName) + headerValues.append(field.value) + } + let statusCode = try transport.send( + method: request.method.rawValue, + url: url, + headerNames: headerNames, + headerValues: headerValues + ) + let responseNames = transport.responseHeaderNames() + let responseValues = transport.responseHeaderValues() + let body = transport.responseBody().withUnsafeBytes { Data($0) } + var responseFields = HTTPFields() + for (name, value) in zip(responseNames, responseValues) { + guard let fieldName = HTTPField.Name(name) else { continue } + responseFields.append(HTTPField(name: fieldName, value: value)) + } + let response = HTTPResponse( + status: HTTPResponse.Status(code: Int(statusCode)), + headerFields: responseFields + ) + return (body, response) + } +} diff --git a/Android/Sources/FuelingAndroid/AndroidHTTPTransport.swift b/Android/Sources/FuelingAndroid/AndroidHTTPTransport.swift new file mode 100644 index 0000000..3ea7f0c --- /dev/null +++ b/Android/Sources/FuelingAndroid/AndroidHTTPTransport.swift @@ -0,0 +1,55 @@ +// +// AndroidHTTPTransport.swift +// FuelingAndroid +// +// JNI callback protocol implemented in Kotlin/Java via swift-java +// (`enableJavaCallbacks`). +// + +/// Flattened, synchronous HTTP transport implemented on the Java/Kotlin side +/// (e.g. with `HttpURLConnection`), so the shared Swift networking stack can +/// run on Android without linking `FoundationNetworking` (and its ~42 MB ICU +/// dependency chain). +/// +/// This is deliberately **not** ``FuelingAPI/HTTPClient``: swift-java's JNI +/// callback bridging supports only synchronous, untyped-`throws` methods whose +/// parameters and returns are primitives, `String`, and arrays of those — no +/// `associatedtype`, typed throws, `async`, or custom structs. See +/// ``AndroidHTTPClient`` for the adapter that bridges this into the real +/// `HTTPClient` protocol. +/// +/// ## Threading and reentrancy +/// ``AndroidHTTPClient`` invokes ``send(method:url:headerNames:headerValues:)`` +/// and the `response*()` accessors as one atomic sequence on a single serial +/// queue, so implementations may store the most recent response in plain +/// instance fields without synchronization — but must not assume any +/// *particular* thread (only that calls never interleave). +public protocol AndroidHTTPTransport { + + /// Perform the request synchronously and return the HTTP status code. + /// + /// `headerNames`/`headerValues` are parallel arrays (JNI callbacks cannot + /// bridge dictionaries). Throw only for transport-level failures + /// (connection refused, timeout, malformed URL) — HTTP error statuses are + /// returned normally as the status code. + func send( + method: String, + url: String, + headerNames: [String], + headerValues: [String] + ) throws -> Int32 + + /// Response header names from the most recently completed ``send``, + /// parallel to ``responseHeaderValues()``. + func responseHeaderNames() -> [String] + + /// Response header values from the most recently completed ``send``, + /// parallel to ``responseHeaderNames()``. + func responseHeaderValues() -> [String] + + /// Response body bytes from the most recently completed ``send``. + /// + /// `Int8` rather than `UInt8`: Java's `byte` is signed, and jextract's + /// callback wrapper fails to compile for `[UInt8]` returns. + func responseBody() -> [Int8] +} diff --git a/Android/Sources/FuelingAndroid/FuelingSession.swift b/Android/Sources/FuelingAndroid/FuelingSession.swift index b1aeb18..d926d08 100644 --- a/Android/Sources/FuelingAndroid/FuelingSession.swift +++ b/Android/Sources/FuelingAndroid/FuelingSession.swift @@ -39,12 +39,14 @@ import AndroidLooper /// thread, before any `Store`/view-model work is scheduled. /// /// ## Networking -/// The `Store` is built with a real `APILocationService` pointed -/// at the injected `serverURL` (see ``init(documentsPath:serverURL:)``), so -/// persistence, search and view models fetch from that server the same way -/// they do on Darwin. Pass an empty or invalid URL to run fully offline -/// instead — seed a starting point with ``seedSampleLocations()`` in that -/// case, since no network call will ever populate the cache. +/// The `Store` is built with an `APILocationService` whose transport is a +/// Kotlin/Java-implemented ``AndroidHTTPTransport`` callback (see +/// ``init(documentsPath:serverURL:transport:)``), pointed at the injected +/// `serverURL` — persistence, search and view models fetch from that server +/// the same way they do on Darwin, without linking `FoundationNetworking`/ICU. +/// Pass an empty or invalid URL to run fully offline instead — seed a +/// starting point with ``seedSampleLocations()`` in that case, since no +/// network call will ever populate the cache. public final class FuelingSession { let store: Store @@ -64,13 +66,25 @@ public final class FuelingSession { /// `FUELING_SERVER_URL` environment variable at Gradle build time, /// defaulting to `http://localhost:8080`). Empty or unparsable strings /// fall back to running fully offline rather than throwing. - public init(documentsPath: String, serverURL: String) throws { + /// - Parameter transport: Kotlin/Java-implemented HTTP transport (e.g. + /// wrapping `HttpURLConnection`) performing the actual requests — see + /// ``AndroidHTTPTransport``. + public init(documentsPath: String, serverURL: String, transport: any AndroidHTTPTransport) throws { #if os(Android) _ = Self.setUpMainLooper #endif let directory = URL(fileURLWithPath: documentsPath, isDirectory: true) let databasePath = directory.appendingPathComponent("Fueling.sqlite").path - let locationService = ServerURL(rawValue: serverURL).map { APILocationService(server: $0) } + // Plain `if let` rather than `Optional.map` — the typed-throws + // `Optional.map` thunk crashed (SIGSEGV) on Android when the closure + // captured the JNI-wrapped `transport` existential. + let locationService: (any LocationService)? + if let server = ServerURL(rawValue: serverURL) { + let client = AndroidHTTPClient(transport: transport) + locationService = APILocationService(client: client, server: server) as any LocationService + } else { + locationService = nil + } self.store = try MainActor.assumeIsolated { try Store(sqliteDatabase: databasePath, locationService: locationService) } @@ -119,10 +133,8 @@ public final class FuelingSession { extension FuelingSession { /// A few sample locations, built directly from the `CoreFueling` domain - /// types (not the network DTOs — `FuelingAPI`/`HTTPTypesFoundation`'s - /// `FoundationNetworking` dependency is excluded from the Android build - /// entirely, see the package manifest), so the Locations screen has data - /// without a real network transport. + /// types, so the Locations screen has data when running offline (blank or + /// unparsable `serverURL`). static func sampleLocationData() -> [ModelData] { let fuelOptions: [FuelOption] = [ FuelOption(id: .diesel, name: "Diesel"), diff --git a/Android/Sources/FuelingAndroid/LocationsScreen.swift b/Android/Sources/FuelingAndroid/LocationsScreen.swift index 4837ca5..3ad5bb3 100644 --- a/Android/Sources/FuelingAndroid/LocationsScreen.swift +++ b/Android/Sources/FuelingAndroid/LocationsScreen.swift @@ -32,8 +32,7 @@ public final class LocationsScreen { // MARK: - Lifecycle - /// Reload the location list (fetches from the local cache; networking is - /// not yet wired up on Android). + /// Reload the location list from cache and (when stale) the network. public func reload() { MainActor.assumeIsolated { viewModel.reload() } } diff --git a/Android/Sources/FuelingAndroid/swift-java.config b/Android/Sources/FuelingAndroid/swift-java.config index a679c55..f84704b 100644 --- a/Android/Sources/FuelingAndroid/swift-java.config +++ b/Android/Sources/FuelingAndroid/swift-java.config @@ -1,5 +1,5 @@ { "javaPackage": "com.fuelingapp.jni", "mode": "jni", - "enableJavaCallbacks": false + "enableJavaCallbacks": true } diff --git a/Android/app/src/main/kotlin/com/fuelingapp/FuelingViewModel.kt b/Android/app/src/main/kotlin/com/fuelingapp/FuelingViewModel.kt index dcf586c..b624c38 100644 --- a/Android/app/src/main/kotlin/com/fuelingapp/FuelingViewModel.kt +++ b/Android/app/src/main/kotlin/com/fuelingapp/FuelingViewModel.kt @@ -97,7 +97,7 @@ class FuelingViewModel : ViewModel() { this.arena = arena pollJob = viewModelScope.launch { try { - val session = FuelingSession.init(documentsPath, serverUrl, arena) + val session = FuelingSession.init(documentsPath, serverUrl, HttpURLConnectionTransport(), arena) this@FuelingViewModel.session = session if (serverUrl.isBlank()) { session.seedSampleLocations() diff --git a/Android/app/src/main/kotlin/com/fuelingapp/HttpURLConnectionTransport.kt b/Android/app/src/main/kotlin/com/fuelingapp/HttpURLConnectionTransport.kt new file mode 100644 index 0000000..5c103a2 --- /dev/null +++ b/Android/app/src/main/kotlin/com/fuelingapp/HttpURLConnectionTransport.kt @@ -0,0 +1,69 @@ +package com.fuelingapp + +import com.fuelingapp.jni.AndroidHTTPTransport +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URI + +/** + * [AndroidHTTPTransport] implemented with the platform's own + * [HttpURLConnection], so the shared Swift networking stack works on Android + * without linking `FoundationNetworking` (and its ICU dependency chain). + * + * The Swift side (`AndroidHTTPClient`) calls [send] and the `response*` + * accessors as one atomic sequence on a single serial queue, so the + * last-response fields need no synchronization. + */ +class HttpURLConnectionTransport : AndroidHTTPTransport { + + private companion object { + const val TIMEOUT_MS = 15_000 + } + + private var lastResponseHeaderNames: Array = emptyArray() + private var lastResponseHeaderValues: Array = emptyArray() + private var lastResponseBody: ByteArray = ByteArray(0) + + @Throws(IOException::class) + override fun send( + method: String, + url: String, + headerNames: Array, + headerValues: Array, + ): Int { + val connection = URI(url).toURL().openConnection() as HttpURLConnection + try { + connection.requestMethod = method + connection.connectTimeout = TIMEOUT_MS + connection.readTimeout = TIMEOUT_MS + for (i in headerNames.indices) { + connection.setRequestProperty(headerNames[i], headerValues[i]) + } + + val status = connection.responseCode + val names = mutableListOf() + val values = mutableListOf() + for ((name, headerValueList) in connection.headerFields) { + // The status line appears as a null-named header; skip it. + if (name == null) continue + for (value in headerValueList) { + names.add(name) + values.add(value) + } + } + lastResponseHeaderNames = names.toTypedArray() + lastResponseHeaderValues = values.toTypedArray() + val stream = if (status >= 400) connection.errorStream else connection.inputStream + lastResponseBody = stream?.use { it.readBytes() } ?: ByteArray(0) + return status + } finally { + connection.disconnect() + } + } + + override fun responseHeaderNames(): Array = lastResponseHeaderNames + + override fun responseHeaderValues(): Array = lastResponseHeaderValues + + override fun responseBody(): ByteArray = lastResponseBody +} diff --git a/Android/fueling-jni/build.gradle.kts b/Android/fueling-jni/build.gradle.kts index 81fbd2f..d8ef5dc 100644 --- a/Android/fueling-jni/build.gradle.kts +++ b/Android/fueling-jni/build.gradle.kts @@ -41,9 +41,15 @@ val swiftToolchainVersion: String = (findProperty("swiftToolchainVersion") as St val swiftBin: String = (findProperty("swiftBin") as String?) ?: "$userHome/Library/Developer/Toolchains/swift-$swiftToolchainVersion-RELEASE.xctoolchain/usr/bin/swift" -// swift-java's `enableJavaCallbacks` feature would need its own JDK 25 sub-build; -// unused here (see Android/Sources/FuelingAndroid/swift-java.config), so no -// `jdk25Home` property is needed. +// swift-java's `enableJavaCallbacks` feature (used for the `AndroidHTTPTransport` +// JNI callback) runs its own internal Gradle sub-build during `swift build` to +// compile the generated Java callback interfaces against its SwiftKitCore module — +// independent of whatever JDK runs *this* Gradle build, and requiring a one-time +// network fetch of that sub-build's own Gradle distribution after a `.build` wipe. +// Override with `-Pjdk25Home=/path/to/jdk` where Homebrew's prefix doesn't apply +// (e.g. CI). +val jdk25Home: String = (findProperty("jdk25Home") as String?) + ?: "/opt/homebrew/opt/openjdk@25" // The first path segment is the lowercased *directory name* of the Swift package // (SwiftPM's `outputs///...` convention) — "android" here, since @@ -71,6 +77,7 @@ val ndkRoot = File( val jextract = tasks.register("jextract") { workingDir = swiftPackageRoot environment("SWIFT_BUILD_DYNAMIC_LIBRARY", "1") + environment("JAVA_HOME", jdk25Home) commandLine( swiftBin, "build", "--swift-sdk", androidTriple, @@ -99,14 +106,26 @@ val stageJniLibs = tasks.register("stageJniLibs") { include("*.so") // Test-only runtime libraries are not needed by consumers. exclude("*Testing*", "libXCTest.so") - // `FuelingModel` depends on `FuelingAPI` (real networking, via - // `HTTPTypesFoundation`'s `FoundationNetworking`) unconditionally now — - // `libFoundationNetworking.so` must be staged, or the whole native - // library fails to load at runtime (`UnsatisfiedLinkError: library - // "libFoundationNetworking.so" not found`), not just the networking - // call path. `libFoundationXML.so` isn't linked (no XML parsing - // anywhere in this app) and is the only one still excluded. - exclude("libFoundationXML.so") + // Networking goes through the Kotlin `AndroidHTTPTransport` JNI + // callback (`HttpURLConnection`), not `URLSession`, and every library + // in the graph prefers `FoundationEssentials` over the full + // `Foundation` umbrella — so `libFoundationNetworking.so`, + // `libFoundation.so`, `libFoundationInternationalization.so`, and the + // ~42 MB `lib_FoundationICU.so` are all absent from every library's + // DT_NEEDED chain (verified with `llvm-readobj --needed-libs`) and + // aren't staged. `libFoundationXML.so` isn't linked either (no XML + // parsing anywhere in this app). If a stray full-`Foundation` import + // sneaks back into the graph, the app fails to load with an + // `UnsatisfiedLinkError` naming the missing library — re-check the + // autolink entries (`llvm-readelf -p .swift1_autolink_entries`) to + // find the culprit object file. + exclude( + "libFoundationXML.so", + "libFoundationNetworking.so", + "libFoundation.so", + "libFoundationInternationalization.so", + "lib_FoundationICU.so" + ) } from(File(ndkRoot, "toolchains/llvm/prebuilt/darwin-x86_64/sysroot/usr/lib/aarch64-linux-android")) { include("libc++_shared.so") diff --git a/Package.resolved b/Package.resolved index f51881d..2f09fe3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "82f2ec5c978ad10e18090c86c4ba944c8aac858018c26c5aed55c62288cbae7c", + "originHash" : "d0598e83f2b2806716655b5032a402046dd1efdff757df1d642ead8b025cb10a", "pins" : [ { "identity" : "coremodel", @@ -16,7 +16,7 @@ "location" : "https://github.com/PureSwift/CoreModel-SQLite", "state" : { "branch" : "master", - "revision" : "e96bebbb230646ffc06dda584faef5f3e9d49681" + "revision" : "07b8c3ab7e7e7f687b1fb4b56c66689289fa960f" } }, { diff --git a/Package.swift b/Package.swift index 716a05f..5e4d137 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,7 @@ import PackageDescription import class Foundation.ProcessInfo let darwin: [Platform] = [.macOS, .iOS, .tvOS, .watchOS, .visionOS, .macCatalyst] +let nonAndroidPlatforms: [Platform] = darwin + [.linux, .windows, .wasi, .openbsd] // The Android jextract/JNI build (see Android/fueling-jni/build.gradle.kts) cross-compiles // this package with this flag set, so every library in the graph — including this package's @@ -83,9 +84,13 @@ let package = Package( name: "HTTPTypes", package: "swift-http-types" ), + // Not on Android: its `URLSession` bridge links + // `FoundationNetworking` + its ~42 MB ICU chain, and Android + // uses a JNI-callback transport instead (see `FuelingAndroid`). .product( name: "HTTPTypesFoundation", - package: "swift-http-types" + package: "swift-http-types", + condition: .when(platforms: nonAndroidPlatforms) ) ] ), diff --git a/README.md b/README.md index c9b1ce3..a027549 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,15 @@ that exports `FuelingModel`/`CoreFueling` to Kotlin over JNI using [swift-java](https://github.com/swiftlang/swift-java) (jextract). See `Android/README.md` for the build/toolchain requirements. -Like the playground app, it fetches from a real server via `URLSession` -(`FoundationNetworking`), with the base URL injected from the -`FUELING_SERVER_URL` environment variable at Gradle build time (baked into -`BuildConfig`, since an installed app has no shell environment of its own to -read at runtime) — defaulting to `http://localhost:8080`. Persistence is a -local SQLite cache, same as the rest of the package. Point an emulator at +Like the playground app, it fetches from a real server — but through a +Kotlin-implemented `HTTPClient` transport (`HttpURLConnection` behind a +swift-java JNI callback) rather than `URLSession`, keeping +`FoundationNetworking` and its ICU dependency chain out of the Android build +entirely. The base URL is injected from the `FUELING_SERVER_URL` environment +variable at Gradle build time (baked into `BuildConfig`, since an installed +app has no shell environment of its own to read at runtime) — defaulting to +`http://localhost:8080`. Persistence is a local SQLite cache, same as the +rest of the package. Point an emulator at `Scripts/test-server.py` running on the host with `-PfuelingServerUrl=http://10.0.2.2:8080` (`10.0.2.2` is the emulator's alias for the host's `localhost`). diff --git a/Sources/FuelingAPI/FuelingAPIClient.swift b/Sources/FuelingAPI/FuelingAPIClient.swift index 1f2cc20..44a4e72 100644 --- a/Sources/FuelingAPI/FuelingAPIClient.swift +++ b/Sources/FuelingAPI/FuelingAPIClient.swift @@ -9,7 +9,6 @@ import FoundationEssentials import Foundation #endif import HTTPTypes -import HTTPTypesFoundation import CoreFueling /// Fueling REST API, implemented over any ``HTTPClient`` transport. @@ -48,10 +47,25 @@ internal extension HTTPClient { server: ServerURL, device deviceID: String ) async throws(FuelingError) -> T { - let url = FuelingAPI.url(for: path, ids: ids, server: server) + // Built from URLComponents rather than `HTTPRequest(method:url:...)` — + // that convenience initializer lives in `HTTPTypesFoundation`, whose + // `URLSession` bridging drags `FoundationNetworking` (and its ~42 MB + // ICU dependency chain) into the Android link. Pure `HTTPTypes` keeps + // this module transport-agnostic on every platform. + let components = FuelingAPI.urlComponents(for: path, ids: ids, server: server) + var requestPath = components.percentEncodedPath.isEmpty ? "/" : components.percentEncodedPath + if let query = components.percentEncodedQuery { + requestPath += "?" + query + } + var authority = components.host ?? "" + if let port = components.port { + authority += ":" + port.description + } let request = HTTPRequest( method: .get, - url: url, + scheme: components.scheme, + authority: authority, + path: requestPath, headerFields: [ .deviceID: deviceID ] @@ -75,12 +89,12 @@ internal extension HTTPClient { } } -/// Build the request URL for an endpoint. -internal func url( +/// Build the request URL components for an endpoint. +internal func urlComponents( for path: String, ids: [Location.ID], server: ServerURL -) -> URL { +) -> URLComponents { var components = URLComponents( url: URL(server: server).appendingPathComponent(path), resolvingAgainstBaseURL: false @@ -90,7 +104,7 @@ internal func url( URLQueryItem(name: "siteIds", value: Location.ID.Prefixed(id: $0).rawValue) } } - return components.url! + return components } public extension HTTPField.Name { diff --git a/Sources/FuelingAPI/URLSession.swift b/Sources/FuelingAPI/URLSession.swift index 5226b51..b48cff4 100644 --- a/Sources/FuelingAPI/URLSession.swift +++ b/Sources/FuelingAPI/URLSession.swift @@ -3,7 +3,11 @@ // FuelingAPI // -#if canImport(Foundation) +// Excluded on Android: the app there supplies its own JNI-callback transport +// (see `FuelingAndroid`), and merely linking `HTTPTypesFoundation`'s +// `URLSession` bridge would pull `FoundationNetworking` + its ~42 MB ICU +// dependency chain into every Android build. +#if canImport(Foundation) && !os(Android) import Foundation #if canImport(FoundationNetworking) import FoundationNetworking diff --git a/Sources/FuelingModel/APILocationService.swift b/Sources/FuelingModel/APILocationService.swift index eab12d0..ff7e71c 100644 --- a/Sources/FuelingModel/APILocationService.swift +++ b/Sources/FuelingModel/APILocationService.swift @@ -70,11 +70,12 @@ public struct APILocationService: LocationService { } // `URLSession` lives in full `Foundation` (via `FoundationNetworking` on -// non-Darwin platforms, including Android), not `FoundationEssentials` — the -// top-level import above prefers the lean subset whenever it's importable, -// so this extension imports what it actually needs directly rather than -// relying on that. -#if canImport(Foundation) +// non-Darwin platforms), not `FoundationEssentials` — the top-level import +// above prefers the lean subset whenever it's importable, so this extension +// imports what it actually needs directly rather than relying on that. +// Excluded on Android, where the transport is a JNI-callback client instead +// (see `FuelingAndroid`) and `URLSession: HTTPClient` does not exist. +#if canImport(Foundation) && !os(Android) #if canImport(FoundationNetworking) import FoundationNetworking #endif diff --git a/Tests/FuelingTests/FuelingAPITests.swift b/Tests/FuelingTests/FuelingAPITests.swift index 619f9bc..a2ede63 100644 --- a/Tests/FuelingTests/FuelingAPITests.swift +++ b/Tests/FuelingTests/FuelingAPITests.swift @@ -39,9 +39,9 @@ struct FuelingAPITests { @Test func requestURL() { let server = ServerURL(rawValue: "https://example.com")! - let url = FuelingAPI.url(for: "v1/fuelprice", ids: [15, 23], server: server) + let url = FuelingAPI.urlComponents(for: "v1/fuelprice", ids: [15, 23], server: server).url! #expect(url.absoluteString == "https://example.com/v1/fuelprice?siteIds=0015&siteIds=0023") - let allURL = FuelingAPI.url(for: "v1/locations", ids: [], server: server) + let allURL = FuelingAPI.urlComponents(for: "v1/locations", ids: [], server: server).url! #expect(allURL.absoluteString == "https://example.com/v1/locations") }