One Codebase, Three Platforms: Shipping Ionic/Capacitor to iOS, Android and PWA
I had just left my junior software engineering job and started my degree. No income, a lot of time, and the kind of confidence that only exists before you're doing. So I did what made sense - I sent out 30 to 40 cold emails to companies that looked like they needed a developer. Most didn't reply. A few did. One of those replies turned into this project.
The brief was to build a fleet telematics app - live video streams from vehicle cameras, panic alerts, GPS tracking, real-time maps - and ship it to iOS, Android and the web. As a solo freelancer. While studying. I said yes before I fully understood what I was agreeing to.
"Write once, run anywhere" is the pitch. What actually happens is you write once, then spend the next however-many-months learning exactly where each platform breaks the promise. I'm at Android version code 81 now. Every one of those builds taught me something the Capacitor docs don't mention - and most of them hurt before they helped.
This isn't a tutorial on setting up Ionic. It's about what actually happens when a complex telematics app meets three different browser engines, two native permission models and a build pipeline that partially breaks itself every time you sync. Written by someone who figured it out alone, one failed build at a time.
The stack
| Layer | Technology | Version |
|---|---|---|
| Framework | Angular | 19.x |
| UI | Ionic | 8.x |
| Native Bridge | Capacitor | 5.x |
| Monorepo | Nx | 20.x |
| CI (iOS) | Codemagic | - |
| Build Output | www/ | - |
Capacitor sits at the centre of everything. It wraps the Angular build output in a native WebView on iOS and Android and exposes device APIs through a plugin system. The config looks deceptively simple:
import { CapacitorConfig } from "@capacitor/cli"; const config: CapacitorConfig = { appId: "za.co.truckassist", appName: "Telematic AI", webDir: "www", bundledWebRuntime: false, plugins: { Geolocation: { enableHighAccuracy: true, timeout: 10000, maximumAge: 300000, }, }, }; export default config;
That maximumAge: 300000 - 5 minutes - is a deliberate trade-off. For general map displays a cached position is fine, saves hammering the GPS radio on every screen transition. But the panic system bypasses it entirely with a fresh position request every time. When a driver hits the emergency button, a 5-minute-old location is useless.
Where the abstraction actually holds up
Before I get into the pain, Capacitor deserves credit for what it handles well. The app initialisation, Firebase auth, routing and the core Ionic UI components all work identically across iOS, Android and the web without a single line of platform-specific code:
async initializeApp() { if (!firebase.apps.length) { firebase.initializeApp(firebaseConfig); } firebase.auth().onAuthStateChanged(async (user) => { if (user) { this.onLoginInit(); } else { this.router.navigate(["auth"]); } }); }
That runs on all three platforms. The auth flow, the navigation, the Ionic modals and toasts - identical everywhere. For a fleet app where the core loop is login, view vehicles, manage alerts, that's a significant amount of functionality you get without thinking about platforms at all.
The problems start at the edges. And in a telematics app, the edges are where most of the interesting features live.
Where the abstraction breaks - and what I did about it
None of these issues showed up in browser dev tools. Every single one was discovered on a real device, usually at the worst possible time.
1. iOS video cleanup and memory pressure
The app streams live video from fleet cameras through the Jessibuca player. On Chrome - Android and desktop - navigating away from the video page tears down the player and the browser reclaims the memory. Clean, automatic, exactly what you'd expect.
On iOS WebKit, that doesn't happen.
Video elements hold onto their media resources even after the component is destroyed. Navigate between a few vehicles' camera feeds and the WebView's memory climbs until iOS kills the app entirely. No crash log, no warning. Just a blank screen and a confused driver.
I had to write cleanup code I never expected to write in a web application:
private performIOSCleanup() { this.performStandardCleanup(); this.cleanupTimeout = setTimeout(() => { this.forceJessibucaCleanup(); try { const videoElements = document.querySelectorAll("video"); videoElements.forEach((video) => { try { video.pause(); video.src = ""; video.load(); } catch (error) {} }); } catch (error) {} if ((window as any).gc) { try { (window as any).gc(); } catch (error) {} } }, 500); }
Manually querying the DOM for every <video> element, pausing them, clearing their source, calling load() to force the browser to release the media resource, then hinting at garbage collection. In a web app. This is what cross-platform looks like when you're dealing with media-heavy features.
The 500ms delay is there because iOS WebKit doesn't process component destruction synchronously. Clean up immediately and you miss half the elements because they're still in the rendering pipeline. The route-leave handler then branches on platform:
if (this.platform.is("ios")) { this.performIOSCleanup(); } else { this.performStandardCleanup(); }
The consequence of getting this wrong was a silently dying app on iOS. The consequence of getting it right was video navigation that actually works across 81 build iterations without a memory complaint.
2. Platform-specific permission dialogs
Geolocation permissions are where Capacitor's abstraction is almost enough - and then isn't.
The @capacitor/geolocation API is genuinely cross-platform. Checking permissions, requesting them, getting a position - all identical code across iOS and Android. The problem I ran into wasn't the API. It was what happens when the user says no.
If a driver denies location permission, Capacitor can't re-prompt - the OS blocks it. The app has to send the user to their phone's settings manually. And those settings screens look completely different on iOS vs Android:
async requestLocationPermission(): Promise<boolean> { try { const permissions = await Geolocation.checkPermissions(); if (permissions.location === "granted") { this._hasLocationPermission.next(true); await this.updateCurrentLocation(); return true; } const result = await Geolocation.requestPermissions(); const granted = result.location === "granted"; this._hasLocationPermission.next(granted); if (granted) { await this.updateCurrentLocation(); return true; } await this.showSettingsDialog(); return false; } catch (error) { this._hasLocationPermission.next(false); return false; } }
When it falls through to showSettingsDialog(), the instructions branch completely by platform:
private async showSettingsDialog(): Promise<void> { const isIOS = this.platform.is("ios"); const instructions = isIOS ? 'To enable location:\n\n' + '1. Go to Settings\n' + '2. Scroll down and tap this app\n' + '3. Tap Location\n' + '4. Select "While Using App" or "Always"\n' + '5. Return to the app and try again' : 'To enable location:\n\n' + '1. Go to Settings\n' + '2. Tap Apps or Application Manager\n' + '3. Find and tap this app\n' + '4. Tap Permissions\n' + '5. Turn on Location\n' + '6. Return to the app and try again'; // ... }
The API is cross-platform. The moment the user denies it, you're writing platform-specific UX anyway. Plan for that from the start.
3. Monkey-patching EventTarget before Angular boots
This one took me an embarrassingly long time to track down.
Chrome was logging warnings about non-passive touch event listeners blocking scroll performance. I initially dismissed them as noise. Then I started getting reports of sluggish scrolling on lower-end Android devices in the field and went back to investigate properly. The warnings were coming from third-party libraries - specifically the Overlapping Marker Spiderfier used on the maps page - that call preventDefault() on touch events internally. I couldn't patch those libraries. The only way out was to intercept the problem before it started.
My fix was a global monkey-patch in main.ts that runs before Angular even initialises:
(function() { const originalAddEventListener = EventTarget.prototype.addEventListener; EventTarget.prototype.addEventListener = function(type, listener, options) { if (type === 'touchstart' || type === 'touchmove' || type === 'touchend') { if (typeof options === 'boolean') { options = { capture: options, passive: true }; } else if (typeof options === 'object' && options !== null) { options = { ...options, passive: true }; } else { options = { passive: true }; } } return originalAddEventListener.call(this, type, listener, options); }; })();
Every touch event listener registered anywhere in the app - Angular, Ionic, third-party libraries, all of it - gets passive: true injected automatically. Scroll-blocking warnings gone.
But now libraries that call preventDefault() inside those passive listeners throw a different console error. So there's a second patch to suppress that:
(function() { const originalError = console.error; console.error = function(...args: any[]) { const message = args.join(' '); if (message.includes( 'Unable to preventDefault inside passive event listener invocation' )) { return; } originalError.apply(console, args); }; })();
Two monkey-patches that run before the application even starts, to fix a scroll performance issue caused by a maps library I can't modify. This is the kind of code that has no presence in any tutorial but exists in every production cross-platform app I've seen. It's unglamorous and it works.
4. iOS initialisation timing
The live video page initialises differently on iOS than on Android.
iOS WebKit needs a brief delay before the component can safely interact with the DOM and start loading streams. Without it the Jessibuca player initialises into a rendering state that hasn't finished layout yet and the canvas renders at the wrong size, or not at all. I spent a frustrating afternoon assuming it was a player configuration issue before I found this:
if (this.platform.is("ios")) { setTimeout(() => { this.initializeComponent(); }, 100); } else if (this.platform.is("android")) { this.initializeComponent(); } else { this.initializeComponent(); }
100 milliseconds. That is the difference between a working video player and a blank screen on iOS. The same logic extends to network timeouts - mobile platforms get 45 seconds versus 30 on the web, because fleet vehicles on cellular are unpredictable:
const timeoutDuration = this.platform.is("mobile") ? 45000 : 30000;
Android: 81 builds and the Gradle tax
The Android project is at version code 81. Every one of those builds went through a pipeline that fights itself at step three.
The build chain looks clean on paper:
"setup": "npm run build && npx cap sync && node scripts/fix-android-build.gradle.js && npm run open-ide", "sync": "npx cap sync && node scripts/fix-android-build.gradle.js",
That `fix-android-build.gradle.js` step isn't optional. Every time `npx cap sync` runs, it regenerates the Gradle files for the `capacitor-cordova-android-plugins` module. The generated output has two problems: it's missing the `namespace` declaration that modern Android Gradle Plugin requires, and it includes a `flatDir` repository that causes dependency resolution conflicts. The project won't compile without fixing both.
So I wrote a script that patches it after every sync:
const fs = require('fs'); const path = require('path'); const buildGradlePath = path.join( __dirname, '..', 'android', 'capacitor-cordova-android-plugins', 'build.gradle' ); if (!fs.existsSync(buildGradlePath)) { console.log('build.gradle file not found, skipping fix'); process.exit(0); } let content = fs.readFileSync(buildGradlePath, 'utf8'); if (!content.includes("namespace 'capacitor.android.plugins'")) { content = content.replace( /(android\s*\{)/, "$1\n namespace 'capacitor.android.plugins'" ); } if (content.includes('flatDir')) { content = content.replace( /(\s+flatDir\s*\{[^}]*\})/, '' ); } fs.writeFileSync(buildGradlePath, content, 'utf8');
37 lines of JavaScript to fix what the sync just generated. I found this around build 15 through trial and error. By build 30 the script was stable enough I stopped thinking about it. Now it's just part of the pipeline - same as any other dependency.
Cleartext traffic and the camera problem
The `AndroidManifest.xml` has `android:usesCleartextTraffic="true"` because fleet cameras stream video over HTTP, not HTTPS. They're embedded devices on vehicles - they don't support TLS. The app needs to accept those HTTP video streams while using HTTPS for all API traffic. There's no elegant solution to that conflict; this is the pragmatic one:
<application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme" android:usesCleartextTraffic="true">
Location permissions are declared with `required="false"` so the app installs on devices without GPS hardware. The permission only gets requested when the driver actually taps the panic button - not on launch:
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-feature android:name="android.hardware.location.gps" android:required="false" />
The SDK range is `minSdkVersion 22` to `targetSdkVersion 35` - Android 5.1 through Android 15, a 10-year span. Some fleet vehicles are still running hardware from 2015. The build uses the latest tooling; the runtime has to handle whatever's actually bolted to the truck.
iOS: ATS, permissions and Codemagic CI
iOS has the same cleartext problem as Android, expressed through Apple's App Transport Security. ATS blocks non-HTTPS connections by default. The cameras don't support TLS. The blunt solution:
<key>NSAppTransportSecurity</key> <dict> <key>NSAllowsArbitraryLoads</key> <true/> </dict>
Ideally you'd whitelist only the camera domains using `NSExceptionDomains`. But camera IP addresses are dynamic per fleet deployment - a static whitelist isn't practical. `NSAllowsArbitraryLoads: true` is the pragmatic answer to an impractical constraint.
The location usage string matters more than it looks. Apple rejects apps with vague descriptions. Ours is specific:
<key>NSLocationWhenInUseUsageDescription</key> <string>This app uses location so your device can be located during a PANIC event.</string>
That wording ties the permission directly to the panic feature. The user knows exactly what they're agreeing to - and so does the App Store reviewer.
Codemagic - iOS CI without owning a Mac rack
Building for iOS requires macOS, Xcode and Apple's code signing infrastructure. Managing that manually is a nightmare. Codemagic handles it:
scripts: - name: Install npm dependencies script: npm install - name: Cocoapods installation script: cd ios/App && pod install - name: Copy web assets to native project script: npx cap copy - name: Set up keychain for codesigning script: keychain initialize - name: Fetch signing files script: | app-store-connect fetch-signing-files \ $(xcode-project detect-bundle-id) \ --type IOS_APP_STORE --create - name: Add certificates to keychain script: keychain add-certificates - name: Set up code signing settings script: xcode-project use-profiles - name: Build ipa for distribution script: | xcode-project build-ipa \ --workspace "$XCODE_WORKSPACE" \ --scheme "$XCODE_SCHEME"
Note that it runs `npx cap copy` instead of `cap sync`. Sync regenerates the native project files and would undo manual iOS config changes. Copy only updates the web assets inside the existing native project - safer for CI where you don't want surprises.
Push to the branch, Codemagic builds it, TestFlight has it within 20 minutes. No manual Xcode archive cycle. No provisioning profile hunting.
PWA: the third platform that cost almost nothing
I'll be honest - the PWA was an afterthought that turned out surprisingly well.
It's the Angular build output served as a static site with a service worker. Angular's `@angular/service-worker` handles registration. Enabling it in production was a single flag in the Nx build config: `"serviceWorker": true`. The service worker config itself is 28 lines:
{ "configVersion": 3, "index": "/index.html", "assetGroups": [ { "name": "app", "installMode": "prefetch", "resources": { "files": ["/favicon.ico", "/index.html", "/*.css", "/*.js"] } }, { "name": "assets", "installMode": "lazy", "updateMode": "prefetch", "resources": { "files": ["/assets/**"] } } ] }
The app shell prefetches on first visit so it loads instantly on return. Assets - including the Jessibuca WASM decoder which is several megabytes - use lazy caching so they're only stored if the user actually opens the video page. No point prefetching a heavy binary for every fleet manager who never touches the camera feature.
The PWA doesn't do everything. No haptic feedback on the panic button, no native splash screen, no status bar control. But for fleet managers who mostly work from a desktop browser and occasionally check in from their phone, it covers 90% of the use cases without an app store install. No signing certificates, no CI pipeline, no review process. It just ships.
After 81 builds - what I actually learned
When I sent those cold emails I had no idea I'd end up writing monkey-patches that run before Angular boots, or manually querying the DOM to clear video elements because iOS refuses to let go of memory, or maintaining a Node script that fixes what Capacitor's own sync command breaks every single time.
That's cross-platform development. Not "write once, run anywhere" - more like "write once, then chase each platform's specific way of being broken until the thing actually works everywhere."
The real value of Ionic/Capacitor isn't the abstraction. It's that when you do find each platform's breaking point, you fix it once and it stays fixed across all 81 subsequent builds. The codebase compounds. Every edge case you solve stays solved.
If I was starting again I'd test on a real iOS device from day one, not a simulator - the simulator doesn't reproduce memory pressure and it won't show you the 100ms timing issue until a real user calls you about a blank screen. I'd write the post-sync Gradle fix script on build one and stop thinking about it. And I'd build the permission denial recovery flow before I ever shipped to production, because Capacitor's cross-platform API ends exactly at the moment the user says no - everything after that is yours to handle.
The PWA surprised me most. A 28-line service worker config and a build flag, and I had a third platform for almost free. For a solo freelancer trying to deliver maximum value, that's hard to argue with.
Build 81 works. Builds 1 through 80 are the reason why.