# 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](https://github.com/yonaskolb/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`](project.yml) spec with [XcodeGen](https://github.com/yonaskolb/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):
   ```sh
   brew install xcodegen
   ```
2. Generate the project:
   ```sh
   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:
   ```sh
   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`](.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`](.xcode.env) (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:

```sh
# 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`](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):

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

Find your 10-character Team ID in the [Apple Developer portal](https://developer.apple.com/account)
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:

```sh
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`](project.yml) (`packages.Firebase` →
`https://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`](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](https://kotlinlang.org/docs/native-debugging.html#debug-ios-applications).
- The *Firebase Crashlytics* post-compile build phase in [`project.yml`](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`](../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`](iosApp/Info.plist) that the
generated project uses. Background refresh uses `BGTaskScheduler`, which requires the task
identifiers to be declared:

```xml
<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`.
