Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
980243e
Add GitHub Actions CI to build and test iOS and Android
colemancda Jul 19, 2026
725b4b4
Split Swift build and unit tests into separate CI jobs
colemancda Jul 19, 2026
8dc3594
Merge pull request #2 from MillerTechnologyPeru/ci/github-actions
colemancda Jul 19, 2026
8a8745f
Build requests with pure HTTPTypes instead of HTTPTypesFoundation
colemancda Jul 20, 2026
327f018
Exclude URLSession HTTPClient conformance on Android
colemancda Jul 20, 2026
6d392ac
Exclude URLSession convenience initializer on Android
colemancda Jul 20, 2026
91ff5e0
Link HTTPTypesFoundation only on non-Android platforms
colemancda Jul 20, 2026
9e46859
Update request URL test for URLComponents refactor
colemancda Jul 20, 2026
706b65c
Add AndroidHTTPTransport JNI callback protocol
colemancda Jul 20, 2026
61f3d10
Add AndroidHTTPClient adapter bridging the JNI transport to HTTPClient
colemancda Jul 20, 2026
3f9e725
Enable swift-java Java callbacks
colemancda Jul 20, 2026
6e9f526
Inject the Kotlin HTTP transport into FuelingSession
colemancda Jul 20, 2026
7686ed1
Update stale networking comment in LocationsScreen
colemancda Jul 20, 2026
d298dde
Document why FuelingAPI must not be an explicit product dependency
colemancda Jul 20, 2026
ec762af
Implement the HTTP transport in Kotlin with HttpURLConnection
colemancda Jul 20, 2026
dfcaca6
Pass the Kotlin HTTP transport into the session
colemancda Jul 20, 2026
b30e06a
Configure JDK for the Java callbacks sub-build and drop FoundationNet…
colemancda Jul 20, 2026
850d663
Document the Kotlin HTTP transport architecture
colemancda Jul 20, 2026
c8e8403
Update Android networking description
colemancda Jul 20, 2026
aec6ac2
Exclude full Foundation and ICU libraries from Android staging
colemancda Jul 20, 2026
6191fad
Pin CoreModel-SQLite with FoundationEssentials-preferring imports
colemancda Jul 20, 2026
b8241e7
Update CoreModel-SQLite pin in root package
colemancda Jul 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions Android/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions Android/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
66 changes: 48 additions & 18 deletions Android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions Android/Sources/FuelingAndroid/AndroidHTTPClient.swift
Original file line number Diff line number Diff line change
@@ -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<AndroidHTTPClient>` 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)
}
}
55 changes: 55 additions & 0 deletions Android/Sources/FuelingAndroid/AndroidHTTPTransport.swift
Original file line number Diff line number Diff line change
@@ -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]
}
Loading
Loading