Files

iosApp — StockTicker iOS shell

This is the thin SwiftUI host for the Kotlin Multiplatform :shared framework. It hosts the shared Compose Multiplatform UI inside a UIViewController, on top of the shared iOS implementations it wires into a running iOS app:

Shared (Kotlin/Native, shared/src/iosMain) iOS app (Swift, this folder)
UserDefaultsPreferences (UserPreferences + CrumbStore, NSUserDefaults)
BackgroundRefreshScheduler (RefreshScheduler, update-window math) StockTickerBackgroundScheduler (BGTaskScheduler submission)
StocksProvider (IStocksProvider) ContentView / WatchlistModel
Analytics (over shared AnalyticsEvent) StockTickerAnalyticsSink (Firebase or NSLog)
BackgroundTaskScheduler (interface) StockTickerBackgroundScheduler
onQuotesUpdated hook WidgetCenterReloader (WidgetKit) + widget snapshot write
IosPortfolioExchange (PortfolioSerializer + provider) PortfolioDocumentBridgeImpl (document pickers / share sheet)
WidgetSnapshotStore (App Group NSUserDefaults) StockTickerWidget (WidgetKit extension, Swift Charts)
initKoinIos(...) / KoinHelper StockTickerApp calls it at launch
MainViewController() (Compose Multiplatform UI in a UIViewController) ComposeView / ContentView host it

Files

  • StockTickerApp.swift@main entry point. Configures Firebase (when the SDK + a GoogleService-Info.plist are present), starts Koin (IosModuleKt.doInitKoinIos) with the platform background scheduler, analytics sink, crash reporter, the portfolio document-picker bridge and the onQuotesUpdated hook (which writes the WidgetKit snapshot and reloads its timelines), and registers the BGTaskScheduler handlers.
  • StockTickerBackgroundScheduler.swift — implements the shared BackgroundTaskScheduler by submitting BGAppRefreshTaskRequest / BGProcessingTaskRequest, and runs the shared IStocksProvider.fetch / cleanup from the task handlers.
  • StockTickerAnalyticsSink.swift — implements the shared AnalyticsSink; forwards to Firebase when the SDK is linked, otherwise logs.
  • StockTickerCrashReporter.swift — implements the shared CrashReporter; records shared AppLogger errors as Firebase Crashlytics non-fatals and warnings as breadcrumbs when the Crashlytics SDK is linked, otherwise a no-op.
  • PortfolioDocumentBridgeImpl.swift — implements the shared PortfolioDocumentBridge; presents the system UIDocumentPickerViewController (export/import) and UIActivityViewController (share) for the Settings share/import/export actions. The shared IosPortfolioExchange owns the serialization and provider mutations.
  • WidgetCenterReloader.swift — reloads WidgetKit timelines after a refresh.
  • ComposeView.swift — a UIViewControllerRepresentable that hosts the shared Compose Multiplatform UI by bridging MainViewControllerKt.MainViewController() into SwiftUI.
  • ContentView.swift — root SwiftUI view; renders ComposeView edge-to-edge so the shared Kotlin Compose screens drive the whole UI.
  • StockTicker.entitlements — enables the group.com.github.premnirmal.ticker App Group so the app can hand the portfolio snapshot to the widget extension.
  • Info.plist — the app's property list (bundle metadata, BGTaskSchedulerPermittedIdentifiers / UIBackgroundModes for background refresh, launch screen). Referenced by project.yml.
  • Assets.xcassets — the app's asset catalog (AppIcon / AccentColor placeholders).
  • ../project.yml — the XcodeGen spec the .xcodeproj is generated from (see Generating the Xcode project below).

Widget extension — StockTickerWidget/

A native WidgetKit home-screen widget (the iOS counterpart of the Android Glance widget):

  • StockTickerWidget.swift — the Widget, its AppIntentTimelineProvider and SwiftUI views. The provider reads the shared WidgetSnapshotStore (App Group NSUserDefaults) the app writes on every refresh and renders the watchlist; the large family adds a Swift Charts bar chart of each symbol's percent change. Supports small / medium / large families.
  • StockTickerWidgetIntent.swift — the per-widget StockTickerConfigurationIntent (WidgetConfigurationIntent) and its WatchlistSymbolEntity/WatchlistSymbolQuery. This is the iOS counterpart of Android's per-widget Glance options: each placed widget keeps its own watchlist selection (symbols are offered from the shared snapshot) and appearance (sort by change, header, change amount, bold text). Edit a placed widget (touch & hold → Edit Widget) to change it.
  • StockTickerWidgetBundle.swift — the @main WidgetBundle.
  • Info.plist — the com.apple.widgetkit-extension extension point.
  • StockTickerWidget.entitlements — the matching App Group entitlement (must equal the app's).

Generating the Xcode project

The Xcode project is intentionally not committed — it is generated on demand from the declarative iosApp/project.yml spec with XcodeGen, which avoids the fragile, merge-conflict-prone project.pbxproj. The spec already describes both targets (the iosApp application and the StockTickerWidget widget extension), their Info.plist files, the App Group entitlements, the iOS 17 deployment target, and a scheme Build pre-action that builds the shared Shared.framework (once) that the targets link against.

The generated StockTicker.xcodeproj (along with other Xcode artifacts such as *.xcworkspace, xcuserdata/ and DerivedData/) is git-ignored, so it must not be committed — regenerate it locally with the steps below whenever you need it.

On a Mac:

  1. Install XcodeGen (one-time):
    brew install xcodegen
    
  2. Generate the project:
    cd iosApp
    ./version.sh             # stamps the version from the latest git tag
    xcodegen generate        # produces iosApp/StockTicker.xcodeproj
    
    version.sh writes MARKETING_VERSION (CFBundleShortVersionString) and CURRENT_PROJECT_VERSION (CFBundleVersion) into the git-ignored Version.local.xcconfig from git describe --tags, mirroring how the Android app derives its versionName / versionCode in app/build.gradle.kts (falling back to 1.0 / 1 when no tag is reachable). The committed Version.xcconfig (read by every target and optionally including Version.local.xcconfig) already ships non-empty 1.0 / 1 fallbacks, so even if you skip version.sh the bundle version is never empty and the app-extension install still succeeds — running version.sh just stamps the real git-derived version for TestFlight/App Store archives.
  3. Open iosApp/StockTicker.xcodeproj and run, or build from the command line:
    xcodebuild build \
      -project iosApp/StockTicker.xcodeproj \
      -scheme iosApp \
      -destination 'generic/platform=iOS Simulator' \
      CODE_SIGNING_ALLOWED=NO
    

You do not need to build the shared framework separately or wire it up by hand: the generated project runs ./gradlew :shared:embedAndSignAppleFrameworkForXcode as a scheme Build pre-action, which compiles and links the Kotlin/Native Shared.framework for the active configuration/SDK once, before either the app or the widget extension links it. (It is a single scheme pre-action rather than a per-target run-script phase on purpose: running the identical Gradle framework build in both the app and the widget target is redundant, so consolidating it into one up-front pre-action builds the framework a single time. Note this is a simplification, not the fix for the archive hang — the widget extension and both Gradle phases already existed at tag 4.1.000. The "archive hangs at Run custom shell script" symptom was the synchronous Firebase Crashlytics dSYM upload staying in xcodebuild's process group; see the Crashlytics notes below.) The App Groups capability (group.com.github.premnirmal.ticker) is applied to both targets from the committed *.entitlements files, so the app and widget share the WidgetSnapshotStore NSUserDefaults suite.

Java runtime for the Gradle build phase

That Gradle framework build needs a JDK 17+ (the same one the Android build uses). Xcode runs build phases and scheme pre-actions with a minimal environment that does not source your shell profile (~/.zprofile, ~/.zshrc, …), so a java that works in Terminal may be invisible to the script — the build then fails with:

Unable to locate a Java Runtime

To avoid this, the framework pre-action (and the Crashlytics phase) source iosApp/.xcode.env (a committed default that auto-discovers a JDK and exports JAVA_HOME when it is not already set to a valid one) and then the optional iosApp/.xcode.env.local (git-ignored, for a machine-specific override). The default probes, in order: /usr/libexec/java_home, common JDK install locations (the JDK bundled with Android Studio, Homebrew's openjdk, ~/Library/Java/JavaVirtualMachines, /Library/Java/JavaVirtualMachines), and finally a java already on PATH.

If none of those match your setup, point JAVA_HOME at your JDK by creating the git-ignored iosApp/.xcode.env.local (it is sourced after .xcode.env, so it always wins and is never committed), e.g. any of:

# Pick the registered JDK 17 (recommended if you installed a standalone JDK):
echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 17)' > iosApp/.xcode.env.local

# …or point directly at Android Studio's bundled JDK:
echo 'export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home' \
  > iosApp/.xcode.env.local

If a JDK still can't be found, the build phase now fails fast with an error: line in the Xcode log telling you to set JAVA_HOME in iosApp/.xcode.env.local, instead of Gradle's opaque "Unable to locate a Java Runtime".

Note: these scripts are baked into the generated .xcodeproj, so after pulling this change you must regenerate the project (cd iosApp && xcodegen generate) for the fix to take effect.

Continuous integration

.github/workflows/ios.yml runs this on a pinned macos-15 runner (Xcode 16.4, which provides the iOS 18.5 simulator SDK that Compose Multiplatform's Kotlin/Native artifacts are built against) on every PR/master push (and can be triggered manually from any branch via workflow_dispatch — the Actions tab or gh workflow run ios.yml --ref <branch> — so the macOS pipeline can be exercised before merging to master): it links the shared framework, runs the shared commonTest suite on the iOS simulator, then generates the project with XcodeGen and builds the app for the simulator (CODE_SIGNING_ALLOWED=NO, so no signing secrets are needed). Producing a signed .ipa for TestFlight/App Store is intentionally out of scope for this gate — that requires code-signing certificates/profiles supplied as encrypted secrets (or fastlane match) and an xcodebuild archive/-exportArchive (or fastlane) step.

Code signing (for xcodebuild archive)

The simulator builds above pass CODE_SIGNING_ALLOWED=NO, so they need no signing identity. An archive, however, builds the Release configuration, which must be code-signed — with automatic signing that requires a Development Team, otherwise the archive fails with:

error: Signing for "StocksWidget" requires a development team. Select a development team in the
Signing & Capabilities editor. (in target 'StocksWidget' from project 'StocksWidget')

A Team ID is personal, so it is not committed. The committed iosApp/Signing.xcconfig (read by every target via Version.xcconfig) sets CODE_SIGN_STYLE = Automatic and optionally includes the git-ignored iosApp/Signing.local.xcconfig. Set your team there once (no xcodegen generate needed — it is read at build time):

echo 'DEVELOPMENT_TEAM = ABCDE12345' > iosApp/Signing.local.xcconfig

Find your 10-character Team ID in the Apple Developer portal under Membership, or via security find-identity -v -p codesigning. You must also be signed into that Apple Developer account in Xcode (Settings → Accounts) so automatic signing can issue the provisioning profiles. Then:

xcodebuild -project iosApp/StockTicker.xcodeproj \
  -scheme StocksWidget \
  -configuration Release \
  -archivePath build/StockTicker.xcarchive \
  archive

Alternatively, skip the local file and pass the team on the command line (xcodebuild ... archive DEVELOPMENT_TEAM=ABCDE12345).

Firebase (optional, prod only)

Firebase is optional. The FirebaseAnalytics / FirebaseCore SDK is wired into the iosApp target as a Swift Package in project.yml (packages.Firebasehttps://github.com/firebase/firebase-ios-sdk), so all you have to do to enable analytics is drop a GoogleService-Info.plist into the app target (it is git-ignored — see below) and regenerate the project. StockTickerApp.configureFirebase() calls FirebaseApp.configure() only when the SDK is linked and the plist is present; otherwise StockTickerAnalyticsSink falls back to NSLog, mirroring the Android FOSS/dev flavours. Every Firebase use in Swift is guarded by #if canImport(FirebaseAnalytics) / #if canImport(FirebaseCore), so the app still builds even if you remove the Firebase package from project.yml.

The GoogleService-Info.plist is git-ignored (iosApp/iosApp/GoogleService-Info.plist), exactly like the Android google-services.json, so your Firebase config is never committed.

Crash symbolication (Kotlin/Native)

Crashes are reported through Firebase Crashlytics (the FirebaseCrashlytics product is linked in project.yml and auto-initialises once FirebaseApp.configure() runs). For crash reports to show readable Kotlin function names instead of raw addresses, the Kotlin/Native debug information has to be available to Crashlytics:

  • The shared framework is built with -Xadd-light-debug=enable (see shared/build.gradle.kts), which embeds light debug info (function symbols) into the Kotlin/Native binary. Because :shared is a static framework, that code is linked directly into the app binary, so the Kotlin symbols end up in the app's own .dSYM (the project builds with dwarf-with-dsym). See the Kotlin debugging docs.
  • The Firebase Crashlytics post-compile build phase in project.yml runs Crashlytics' run helper and then upload-symbols over the whole ${DWARF_DSYM_FOLDER_PATH}, so every .dSYM (including the Kotlin symbols folded into the app .dSYM) is uploaded to Firebase. It also uploads a standalone Shared.framework.dSYM if one is present (e.g. a future dynamic-framework build). This upload runs only for Release builds and when GoogleService-Info.plist is present: both run and upload-symbols are synchronous, so running them on Debug simulator builds only slows them down (and appears to hang the build over a slow/blocked network) for symbols you never need locally. Even for Release, the entire Crashlytics sequence (the run helper and the upload-symbols invocations) is detached into its own session/process group so the build/archive finishes immediately instead of hanging at "Run custom shell script 'Firebase Crashlytics'" while the network transfer completes. Plain backgrounding (nohup … &, even inside a ( … & ) subshell) is not sufficient: it reparents the process but leaves it in the build phase's process group, and xcodebuild (the archive driver) blocks until that whole process group drains — so the archive keeps waiting on the upload. The job is therefore fork+setsid-ed (via macOS's system Perl, since setsid is not a macOS command) into a fresh process group, with stdio redirected to crashlytics-upload-symbols.log next to DerivedData, so it is no longer part of the phase's group and the phase returns right away.

No extra setup is required beyond dropping in the GoogleService-Info.plist and regenerating the project; release/archive builds upload the symbols automatically.

Crashlytics & Kotlin symbolication

When the FirebaseCrashlytics SDK is linked, StockTickerCrashReporter records shared AppLogger errors as Crashlytics non-fatals (and warnings as breadcrumbs); native crashes and uncaught Kotlin exceptions are captured automatically. For Crashlytics to symbolicate Kotlin frames:

  • The shared Shared framework is built with -Xadd-light-debug=enable (see shared/build.gradle.kts), so Kotlin file/line info is preserved even in optimized release builds.
  • project.yml sets DEBUG_INFORMATION_FORMAT = dwarf-with-dsym, so a dSYM is produced. Because the framework is statically linked, the Kotlin symbols land in the app's own dSYM.
  • Add a Crashlytics run-script build phase that runs both the Crashlytics run and upload-symbols (the latter ships the app dSYM, which now carries the Kotlin symbols, to Firebase). Point its -gsp at the on-disk GoogleService-Info.plist, and do not declare the bundle-copied plist as a script Input File (that creates an Xcode build dependency cycle).

For TestFlight/App Store builds Apple may strip/regenerate dSYMs — download them from App Store Connect and run upload-symbols if Crashlytics reports missing dSYMs.

Required Info.plist entries

These are already declared in the committed iosApp/iosApp/Info.plist that the generated project uses. Background refresh uses BGTaskScheduler, which requires the task identifiers to be declared:

<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
    <string>com.github.premnirmal.ticker.refresh</string>
    <string>com.github.premnirmal.ticker.cleanup</string>
</array>
<key>UIBackgroundModes</key>
<array>
    <string>fetch</string>
    <string>processing</string>
</array>

The identifiers must match StockTickerApp.refreshTaskId / cleanupTaskId.

The portfolio share/import uses UIDocumentPickerViewController; no extra Info.plist entry is required for the document picker or share sheet.

Notes

  • Firebase is optional. Without the FirebaseAnalytics SDK linked, StockTickerAnalyticsSink falls back to NSLog (mirroring the Android FOSS/dev flavours).
  • All business logic (preferences, persistence, networking, refresh scheduling, portfolio import/ export serialization, the widget snapshot) lives in :shared; this folder only provides the iOS platform plumbing the shared code delegates to (document pickers, the WidgetKit timeline + views, Firebase, the App Group store).
  • The UI is also shared: MainViewController() (in shared/src/iosMain) builds a ComposeUIViewController that renders the Compose Multiplatform screens, themed by IosAppTheme. The hosted HomeScreen drives the shared HomeScaffold + bottom navigation; the Watchlist tab binds to the shared IStocksProvider portfolio flow. The theme typography is shared — the brand Ubuntu / Alegreya fonts live in shared Compose resources (shared/src/commonMain/composeResources/font) and IosAppTheme builds its type scale from the shared appTypography(). The iOS host renders the full shared RootNavigationGraph and the colour scheme is unified into a single cross-platform AppTheme.