Fotoshi is a crowdsourced mapping app — "places made visible": contributors capture a photo in the field and it lands on a shared map with precise location, heading, and camera metadata attached, even when it was taken with no signal. I design and build the mobile side of it as the sole mobile engineer.
From one platform to two
In August 2026 I converted Fotoshi from an Android app into a Kotlin Multiplatform project, and it now runs on iOS as well — same features, same screens, one codebase. It was not a rewrite in the destructive sense: the app kept shipping on Android throughout, while the architecture moved underneath it.
The shared module holds about 450 Kotlin files — the domain, the data layer, the view models, and the whole Compose Multiplatform UI, so the screens themselves are written once. Below that sits a thin per-platform layer: 79 files of iOS actuals, 55 of Android, and on the iOS side only three Swift files — the shell, the app entry point, and a Google sign-in bridge.
The hard parts
- Cognito without Amplify. AWS Amplify is JVM and Android only, so signing in on iOS meant implementing the Cognito protocol myself — the SRP handshake and its modular-exponentiation maths in pure Kotlin, over Ktor, in the shared module. Both platforms now use it.
- MapLibre on iOS through cinterop. The map is the product, so it could not be stubbed. I bound the native Objective-C MapLibre SDK into Kotlin and rebuilt every overlay against it — coverage, local places, photo locations, tag features, selection, the location puck.
- A boundary the build enforces. A Detekt rule fails the build when anything in the shared code imports a platform type — the check that keeps "shared" honest as the codebase grows.
- Swapping the Android-only pieces. Retrofit became Ktor, Hilt became Koin, and Room and DataStore moved into the shared module rather than being replaced.
Capture pipeline
- On Android, CameraX (with Camera2 interop) fuses, per photo: a filtered fused-location fix, raw GNSS constellation data, device pose from a sensor-fusion SDK (Zephr), and Camera2 lens/capture metadata
- iOS has its own capture stack behind the same shared interface — preview host, permissions, rotation and metadata — so the app above it does not know which platform it is on
- Gyroscope-optional orientation — falls back to accelerometer + magnetometer fusion, and omits heading rather than failing capture, on devices that lack the sensor
Offline-first by design
- Every capture is queued to a local database first, uploaded to S3 via a presigned URL, and only marked done once a separate server endpoint confirms finalization — with automatic backoff and re-upload if confirmation never arrives
- Lists render instantly and offline from a local mirror; each signed-in user gets an isolated database
Maps & the rest
- A MapLibre-rendered interactive map with runtime style compositing, a sprite-sheet icon system, and feature hit-testing
- Shipping continuously: v0.11.1 brought a personal coverage layer on the explore map and a pending-uploads screen showing every photo's state — waiting, uploading, confirming, failed — with per-photo retry
- A real test suite — unit, instrumented, and screenshot — wired into CI



