Detour

iOS port

Merged into main. iOS-only work happens on the ios branch and merges back; anything under shared/ goes to main directly, because it moves both platforms at once.

Shape

shared/     Kotlin Multiplatform. All roulette/routing/trip logic.
            commonMain compiles for Android and iOS alike.
app/        Android app. UI + platform services only; logic comes from :shared.
iosApp/     SwiftUI app. UI + platform services only; same :shared.

The rule the split follows: the core is handed things, it never reaches for them. Location fixes, audio, Bluetooth and notifications are pushed into :shared by whichever platform is running it. That is why Platform.kt has only three expect declarations (a key-value store, a files directory, a file system) and is not on its way to becoming a second app.

Done

What replaced what

Android-only commonMain
org.json kotlinx.serialization + opt* helpers in Json.kt
HttpURLConnection Ktor — OkHttp on Android, NSURLSession on iOS
java.io.File okio
SharedPreferences expect class Prefs → NSUserDefaults on iOS
BuildConfig BuildDefaults, pushed in at startup by each platform
java.util.Calendar kotlinx-datetime
Math.toRadians Angles.kt

The one structural change forced on callers: HttpURLConnection blocked and Ktor does not, so everything touching the network is now suspend.

Verifying without a Mac

./gradlew :shared:compileCommonMainKotlinMetadata

This type-checks commonMain against the common intersection, which excludes java.*. A stray JDK import fails here on Linux exactly as it would on macOS — even though the ios* targets themselves can only be built on a Mac.

Where the two platforms deliberately differ

Not gaps — decisions, and the places to look first if behaviour diverges.

Not done

  1. The iOS sign-in round trip on real hardware. Signing in works and is shared, but what has actually been exercised is narrower than that sounds: the shared half has unit tests, the Android half was driven on a device, and the iOS half is verified only as far as ios.yml reaches — it compiles, boots the simulator and screenshots. CI cannot reach a private Keycloak, so nobody has yet watched an iPhone complete the browser leg against a real realm. Treat that as untested rather than working.

  2. The stores can still take the app down. The @Throws sweep above covered the suspend surface iOS actually calls — not the whole suspend surface. 17 more public suspend functions in commonMain are exported and still unannotated: Auth.exchangeCode/.signOut, CircleFixes.fixes, Friends.remove, PoiRoulette.randomPoi, RoadRoulette’s randomRoadPoint/fetchRoads/nearestSpeedLimitKmh/ speedLimitWays/rawQuery, RoundTripPlanner.plan, RouteShare.inbox/ .delete, RoutingClient.roundTrip/.randomRoadDestination, SpinPicker.pickCandidate and SyncClient.syncIfDue. None of these is called from Swift today, so there is no live gap for them — but Auth.bearer was on this same list until the convoy relay gave Swift a reason to call it (ConvoyLiveClient.swift’s AuthBearerSource, via the relay’s BearerSource interface): it is annotated and called now, which is exactly the reminder this list exists to give the next function that crosses the same way. SyncClient.syncIfDue is worth naming on its own ahead of time for the identical reason: it sits directly above sync()’s canonical @Throws doc comment in the same file, is Android-only today, and is exactly what an iOS launch-time auto-sync would reach for first. Whoever wires that up has to remember to annotate it then; nothing here does it for them.

    Nor did the sweep cover the non-suspend store functions Swift calls — TraceStore.append/.clear/.rawLines, TripStore.save/ .updateMode/.delete, RouteStore.save/.rename/.remove, SavedPlaces.rename/.remove, BadgeStore.refresh, RecentSearchStore.save — which write through okio.FileSystem and can throw okio.IOException. Because they are not annotated and not suspend, Swift cannot even write try against them, so there is no hint at the call site that a real I/O failure kills the process. The worst pair is TraceStore.append and TripStore.save, which run from TripRecorder during a ride: a phone at zero free space loses the trip and the app with it. A background location launch before first unlock is the other reachable case — default data protection returns EPERM and okio throws.

    Deliberately not fixed alongside sign-in: annotating a non-suspend function is a source-breaking Swift API change (~18 call sites across nine more files must grow try), and the annotation alone fixes nothing — it converts an abort into a throw each site must then handle, mostly by catching and degrading. These back features that already shipped, so they are their own change.

  3. watchOS app. Small, and starting from nothing — the Android watch companion was removed in #57.
  4. Signed device builds. CI builds for the simulator only.

Will not port

Unchanged, and none of these have an iOS route.

Building it

Neither path needs a Mac except the last line.

CI (no Mac): push to main or ios touching shared/ or iosApp/, open a PR that does, or run the iOS workflow by hand. Two artifacts come out of it:

Artifact What it is
Detour-simulator.app.zip Debug build for the simulator. xcrun simctl install booted Detour.app on any Mac.
Detour-unsigned.ipa Release, arm64, for a real phone — unsigned.

Plus screenshot.png, which is the closest thing to looking at the app without a Mac.

The .ipa is unsigned because signing needs a certificate from a paid Apple Developer account and nothing in CI has one. To get it onto a phone you re-sign it yourself — Sideloadly or AltStore on Windows/Linux/macOS, or xcodebuild -exportArchive on a Mac with your account. That is the wall the $99/yr buys past; no CI trick removes it.

On a Mac:

brew install xcodegen
cp iosApp/Config.example.xcconfig iosApp/Config.xcconfig   # optional; endpoints
cd iosApp && xcodegen && open Detour.xcodeproj

The Xcode project is generated, not committed. A pre-build phase runs :shared:packForXcode, so editing Kotlin and pressing Run rebuilds both halves.

On a physical iPhone: needs an Apple Developer account ($99/yr) for a signing certificate and TestFlight. Nothing in CI removes that.