feat(ios): iOS build pipeline + XCFramework, fix K/Native source bugs

Modules: chart-realtime (commonMain, iosMain), root, .paul

- chart-realtime/build.gradle.kts: applyDefaultHierarchyTemplate() + XCFramework("ChartRealtime") over iosX64/iosArm64/iosSimulatorArm64, baseName CamelCase
- commonMain RealtimeChartState/CircularBuffer: switch to kotlin.concurrent.Volatile, add kotlin.jvm.JvmName import (OptionalExpectation)
- commonMain AxisRenderer: replace JVM-only String.format with multiplatform formatFixed/formatScientific/pow10 helpers
- iosMain Platform.ios.kt: NSDate() + timeIntervalSinceReferenceDate + 978_307_200s 1970-epoch offset (K/Native binding has no direct timeIntervalSince1970)
- gradle.properties: remove Linux JDK pin (rely on JAVA_HOME)
- .paul: phases plan, STATE+ROADMAP updates

Acceptance: A1 compile 3 iOS targets, A2 linkDebugFrameworkIosSimulatorArm64, A3 iosSimulatorArm64Test (33 tests, 0 failures), A4 assembleChartRealtimeXCFramework (ios-arm64 + ios-arm64_x86_64-simulator slices), A5 Android assembleRelease/publishToMavenLocal --dry-run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trentin Davide 2026-05-21 12:45:21 +02:00
parent 73bbd9ddec
commit d6f51cdef2
9 changed files with 228 additions and 29 deletions

View file

@ -10,7 +10,8 @@ KMP real-time chart library. Six phases v0.1.0 MVP shipped; v0.2.0 polish shippe
|-----------|---------|--------|-----------| |-----------|---------|--------|-----------|
| v0.1.0 MVP | 0.1.0 | ✓ Shipped | 2026-05-20 | | v0.1.0 MVP | 0.1.0 | ✓ Shipped | 2026-05-20 |
| v0.2.0 Polish | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 | | v0.2.0 Polish | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
| v0.3.0 (TBD) | - | Not started | - | | v0.3.0 iOS Build Pipeline | 0.5.0-SNAPSHOT | ✓ Shipped | 2026-05-21 |
| v0.4.0 (TBD) | - | Not started | - |
--- ---
@ -56,16 +57,37 @@ Summary: .paul/phases/v0.2.0-plan-SUMMARY.md
--- ---
## v0.3.0 — Potential Scope (TBD) ## v0.3.0 — iOS Build Pipeline (SHIPPED)
Status: ✓ Complete
Plan: .paul/phases/v0.3.0-ios-build-plan.md
### Delivered
- 3 iOS targets compile green (iosX64, iosArm64, iosSimulatorArm64)
- `applyDefaultHierarchyTemplate()` wires iosMain/iosTest across targets
- `linkDebugFrameworkIosSimulatorArm64` green
- `iosSimulatorArm64Test` green — 33 tests, 0 failures
- XCFramework `ChartRealtime` produced (debug + release): ios-arm64 + ios-arm64_x86_64-simulator slices
- Pre-existing compile bugs fixed:
- `@Volatile``kotlin.concurrent.Volatile` import
- `@JvmName``kotlin.jvm.JvmName` import (OptionalExpectation)
- `String.format` in AxisRenderer → manual `formatFixed`/`formatScientific`/`pow10` helpers
- `NSDate.date()``NSDate()` + `timeIntervalSinceReferenceDate` + 1970 epoch offset
- Android non-regression confirmed (`assembleRelease`, `publishToMavenLocal --dry-run`)
- Local Mac config: removed Linux JDK pin from `gradle.properties`, added `local.properties` for Android SDK
---
## v0.4.0 — Potential Scope (TBD)
| Item | Effort | Priority | | Item | Effort | Priority |
|------|--------|----------| |------|--------|----------|
| Dirty flag render (skip frame if no new data) | S | High — thermal | | Publish to Maven Central / GitHub Packages | M | Medium |
| targetFps=30 default | S | High — thermal | | iOS demo app (SwiftUI) | L | Medium |
| KDoc on public API surface | M | Medium | | CI workflow GitHub Actions (Android + iOS) | M | Medium |
| README with integration example | S | Medium | | Compose compiler stability annotation on SignalConfig | S | Low |
| Publish to Maven Central / GitHub Packages | M | Low | | CocoaPods / SPM publication | M | Low |
| iOS expect/actual (beyond currentTimeMs) | L | Future |
--- ---
*Roadmap updated: 2026-05-21* *Roadmap updated: 2026-05-21*

View file

@ -5,25 +5,26 @@
See: .paul/PROJECT.md (updated 2026-05-21) See: .paul/PROJECT.md (updated 2026-05-21)
**Core value:** Android devs plot 200Hz+ multi-signal sensor data without frame drops **Core value:** Android devs plot 200Hz+ multi-signal sensor data without frame drops
**Current focus:** v0.3.0 IN PROGRESS — dirty flag render + targetFps=30 shipped **Current focus:** v0.3.0 iOS build pipeline SHIPPED — 33 tests pass on iOS sim, XCFramework produced
## Current Position ## Current Position
Milestone: v0.2.0-polish Milestone: v0.3.0-ios-build
Phase: 6 of 6 — Complete Phase: 6 of 6 — Complete
Plan: .paul/phases/v0.2.0-plan.md Plan: .paul/phases/v0.3.0-ios-build-plan.md
Status: Complete Status: Complete
Last activity: 2026-05-21 — dirty flag render, targetFps=30, 17 unit tests, KDoc, README, iOS targets (iosX64/iosArm64/iosSimulatorArm64) + Platform.ios.kt actual. Last activity: 2026-05-21 — iOS pipeline: compile 3 targets, link debug framework, iosSimulatorArm64Test green (33 tests, 0 failures), XCFramework "ChartRealtime" output (ios-arm64 + ios-arm64_x86_64-simulator slices). Android non-regression confirmed.
Progress: Progress:
- v0.1.0 milestone: [██████████] 100% SHIPPED - v0.1.0 milestone: [██████████] 100% SHIPPED
- v0.2.0 milestone: [██████████] 100% SHIPPED - v0.2.0 milestone: [██████████] 100% SHIPPED
- v0.3.0 milestone: [██████████] 100% SHIPPED
## Loop Position ## Loop Position
``` ```
PLAN ──▶ APPLY ──▶ UNIFY PLAN ──▶ APPLY ──▶ UNIFY
✓ ✓ ✓ [v0.2.0 SHIPPED — v0.5.0-SNAPSHOT] ✓ ✓ ✓ [v0.3.0 iOS build SHIPPED — v0.5.0-SNAPSHOT]
``` ```
## Accumulated Context ## Accumulated Context
@ -41,6 +42,11 @@ PLAN ──▶ APPLY ──▶ UNIFY
| Sensor cap 200Hz (5_000μs) | v0.2.0 | Thermal/battery optimization | | Sensor cap 200Hz (5_000μs) | v0.2.0 | Thermal/battery optimization |
| Package dev.dtrentin | v0.2.0 | Personal project, not company | | Package dev.dtrentin | v0.2.0 | Personal project, not company |
| iOS targets shipped | v0.3.0 | iosX64/iosArm64/iosSimulatorArm64 + Platform.ios.kt | | iOS targets shipped | v0.3.0 | iosX64/iosArm64/iosSimulatorArm64 + Platform.ios.kt |
| iOS build pipeline green | v0.3.0 iOS | 33 tests pass on iOS sim, XCFramework "ChartRealtime" produced |
| timeIntervalSinceReferenceDate + epoch offset | v0.3.0 iOS | K/Native binding doesn't expose timeIntervalSince1970; use 978_307_200s constant |
| applyDefaultHierarchyTemplate() explicit | v0.3.0 iOS | iosMain/iosTest auto-wired across 3 iOS targets |
| Manual format helpers in AxisRenderer | v0.3.0 iOS | String.format JVM-only; private formatFixed/formatScientific/pow10 |
| kotlin.concurrent.Volatile + kotlin.jvm.JvmName imports | v0.3.0 iOS | Multiplatform replacements for JVM-default annotations |
### Deferred Issues ### Deferred Issues
@ -51,21 +57,27 @@ PLAN ──▶ APPLY ──▶ UNIFY
| ~~iOS expect/actual beyond currentTimeMs~~ | Init | L | ✓ v0.3.0 | | ~~iOS expect/actual beyond currentTimeMs~~ | Init | L | ✓ v0.3.0 |
| ~~KDoc on public API surface~~ | v0.2.0 | M | ✓ v0.3.0 | | ~~KDoc on public API surface~~ | v0.2.0 | M | ✓ v0.3.0 |
| ~~README with integration example~~ | v0.2.0 | S | ✓ v0.3.0 | | ~~README with integration example~~ | v0.2.0 | S | ✓ v0.3.0 |
| Publish to Maven Central / GitHub Packages | v0.2.0 | M | v0.3.0 | | ~~iOS build/test pipeline (compile + iosTest + XCFramework)~~ | v0.3.0 plan | M | ✓ v0.3.0 iOS |
| Publish to Maven Central / GitHub Packages | v0.2.0 | M | v0.4.0 |
| iOS demo app (SwiftUI) | v0.3.0 iOS | L | v0.4.0 |
| CI workflow for iOS build (GitHub Actions) | v0.3.0 iOS | M | v0.4.0 |
| Compose compiler unstable warning on SignalConfig | v0.3.0 iOS | S | v0.4.0 |
### Blockers/Concerns ### Blockers/Concerns
None. None.
## Session Continuity ## Session Continuity
Last session: 2026-05-21 Last session: 2026-05-21 (iOS build pipeline)
Stopped at: v0.2.0 complete. All AC pass. Git pushed to remote. Stopped at: v0.3.0 iOS shipped. All 5 acceptance items pass (A1-A5). 33 tests green on iOS sim. ChartRealtime.xcframework at chart-realtime/build/XCFrameworks/release/.
Next action: Define v0.3.0 scope (dirty flag render, targetFps=30, KDoc, publish) Next action: Define v0.4.0 scope (Maven Central publish, iOS demo app, CI)
Resume context: Resume context:
- Library: AndroidCharts/chart-realtime, published dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT to mavenLocal - Library: chart-realtime, published dev.dtrentin:chart-realtime:0.5.0-SNAPSHOT to mavenLocal
- App: AndroidChartsApp consuming library, builds clean - App: AndroidChartsApp consuming library, builds clean
- Remote: ssh://git@3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git - Remote: ssh://git@3nt-git.duckdns.org:222/davide.trentin/KMPCharts.git
- Package: dev.dtrentin.chart - Package: dev.dtrentin.chart
- iOS: 3 targets compile, link, test (33 tests). XCFramework name `ChartRealtime`, baseName CamelCase.
- Local Mac setup: Xcode.app at /Applications/Xcode.app, iOS sim runtime installed (iPhone 17 family), local.properties points to ~/Library/Android/sdk, JDK 21 Temurin via JAVA_HOME.
--- ---
*STATE.md — Updated after every significant action* *STATE.md — Updated after every significant action*

View file

@ -0,0 +1,109 @@
# v0.3.0 — iOS Build & Test Pipeline Plan
## Goal
Wire `chart-realtime` for full iOS pipeline: compile all 3 iOS targets, link debug framework on `iosSimulatorArm64`, run `commonTest` on iOS sim, produce release `XCFramework`. Keep Android green. No new features, no demo app, no SPM/CocoaPods/CI.
Acceptance:
- A1: `./gradlew :chart-realtime:compileKotlinIosX64 compileKotlinIosArm64 compileKotlinIosSimulatorArm64` green
- A2: `./gradlew :chart-realtime:linkDebugFrameworkIosSimulatorArm64` green
- A3: `./gradlew :chart-realtime:iosSimulatorArm64Test` green, executes existing common tests
- A4: `./gradlew :chart-realtime:assembleXCFramework` produces `chart-realtime/build/XCFrameworks/release/chart-realtime.xcframework`
- A5: `./gradlew :chart-realtime:assemble` green (Android non-regression)
## Locked decisions
- T0: remove `org.gradle.java.home` pin (rely on JAVA_HOME)
- XCFramework baseName: `ChartRealtime` (CamelCase iOS convention)
- Include `iosX64` target in XCFramework
- Local Mac dev only, no CI
---
## Tasks
### T0 — Mac JDK / gradle.properties sanity
- Goal: Unblock local Mac build. Current `gradle.properties` pins Linux JDK path. Pre-req for A1A5.
- Files: `gradle.properties`
- Agent: `gradle-expert`
- Action: remove line `org.gradle.java.home=/home/dtrentin/.local/share/JetBrains/Toolbox/apps/android-studio/jbr`. Keep other lines unchanged.
- Deps: none
- Verify: `./gradlew :chart-realtime:help`
### T1+T2+T4 — Batched build.gradle.kts edit
- Goal: iOS hierarchy explicit + XCFramework task.
- Files: `chart-realtime/build.gradle.kts`
- Agent: `gradle-expert` (single edit, single agent)
- Actions:
- Apply default hierarchy template explicitly (`applyDefaultHierarchyTemplate()`)
- Configure each iOS target with `binaries.framework { baseName = "ChartRealtime"; xcf.add(this) }` using `XCFramework("ChartRealtime")` helper from `org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework`
- Keep `compose.material3` in `commonMain` (CMP 1.8.0 supports iOS native)
- Do not break Android target, publishing, or existing source-set deps
- Deps: T0
- Verify:
- `./gradlew :chart-realtime:compileKotlinIosX64`
- `./gradlew :chart-realtime:compileKotlinIosArm64`
- `./gradlew :chart-realtime:compileKotlinIosSimulatorArm64`
- `./gradlew :chart-realtime:linkDebugFrameworkIosSimulatorArm64`
- `./gradlew :chart-realtime:assembleXCFramework`
### T3 — iosTest wiring
- Goal: `iosTest` source set runs existing `commonTest` files on iOS sim.
- Files: `chart-realtime/build.gradle.kts` (only if hierarchy template doesn't auto-wire)
- Agent: `unit-test-writer`
- Inputs: existing `commonTest.dependencies { implementation(libs.kotlin.test) }`. Default hierarchy template wires `iosTest → commonTest` automatically. Verify; add explicit block only if test discovery fails.
- Deps: T1+T2+T4
- Verify: `./gradlew :chart-realtime:iosSimulatorArm64Test`
### T5 — Android non-regression guard
- Goal: Confirm Android target/build/publish untouched.
- Agent: `android-build-resolver`
- Deps: T1+T2+T4
- Verify: `./gradlew :chart-realtime:assembleRelease publishToMavenLocal --dry-run`
### T6 — Smoke acceptance run
- Goal: Sequential run of all 5 acceptance commands.
- Agent: `general-purpose`
- Deps: T3, T5
- Verify: all 5 commands green
---
## Parallelism
| Group | Tasks |
|-------|-------|
| P0 | T0 |
| P1 | T1+T2+T4 (one batched agent call) |
| P2 | T3, T5 (parallel) |
| P3 | T6 |
| P4 | Unify |
---
## Risks
- R1: gradle.properties Linux JDK path — fixed in T0
- R2: `compose.material3` iOS native — supported in 1.8.0; fallback = isolate to non-iOS if compile fails
- R3: Hierarchy template silent inactivity — `applyDefaultHierarchyTemplate()` made explicit
- R4: `commonTest` not seen by `iosTest` — same hierarchy fix
- R5: XCFramework name collision — single `XCFramework("ChartRealtime")` registration
- R6: Compose link errors on iOS — defer to v0.4.0 if hit
- R7: Android regression — T5 final gate, revert if needed
- R8: Sim runtime requirement — confirmed installed (iPhone 17 family available)
## New Deps
None. KGP + CMP 1.8.0 + Kotlin 2.1.0 cover everything.
## Verification Commands
```bash
./gradlew :chart-realtime:help # T0
./gradlew :chart-realtime:compileKotlinIosX64 # A1
./gradlew :chart-realtime:compileKotlinIosArm64 # A1
./gradlew :chart-realtime:compileKotlinIosSimulatorArm64 # A1
./gradlew :chart-realtime:linkDebugFrameworkIosSimulatorArm64 # A2
./gradlew :chart-realtime:iosSimulatorArm64Test # A3
./gradlew :chart-realtime:assembleXCFramework # A4
ls chart-realtime/build/XCFrameworks/release/ChartRealtime.xcframework # A4
./gradlew :chart-realtime:assembleRelease # A5
./gradlew :chart-realtime:publishToMavenLocal --dry-run # T5
```

View file

@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework
plugins { plugins {
alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
@ -10,6 +12,8 @@ group = "dev.dtrentin"
version = "0.5.0-SNAPSHOT" version = "0.5.0-SNAPSHOT"
kotlin { kotlin {
applyDefaultHierarchyTemplate()
androidTarget { androidTarget {
compilations.all { compilations.all {
compileTaskProvider.configure { compileTaskProvider.configure {
@ -21,9 +25,17 @@ kotlin {
publishLibraryVariants("release") publishLibraryVariants("release")
} }
iosX64() val xcf = XCFramework("ChartRealtime")
iosArm64() listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64() iosSimulatorArm64()
).forEach { target ->
target.binaries.framework {
baseName = "ChartRealtime"
xcf.add(this)
}
}
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {

View file

@ -5,6 +5,8 @@ import dev.dtrentin.chart.buffer.TieredBuffer
import dev.dtrentin.chart.model.ChartConfig import dev.dtrentin.chart.model.ChartConfig
import dev.dtrentin.chart.model.SignalConfig import dev.dtrentin.chart.model.SignalConfig
import dev.dtrentin.chart.model.T0 import dev.dtrentin.chart.model.T0
import kotlin.concurrent.Volatile
import kotlin.jvm.JvmName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow

View file

@ -1,5 +1,7 @@
package dev.dtrentin.chart.buffer package dev.dtrentin.chart.buffer
import kotlin.concurrent.Volatile
/** /**
* Lock-free circular buffer for (timestampMs, value) sensor samples. * Lock-free circular buffer for (timestampMs, value) sensor samples.
* Pre-allocated zero allocations after construction. * Pre-allocated zero allocations after construction.

View file

@ -164,15 +164,50 @@ internal object AxisRenderer {
private fun formatAxisValue(value: Double, decimals: Int): String { private fun formatAxisValue(value: Double, decimals: Int): String {
val absVal = kotlin.math.abs(value) val absVal = kotlin.math.abs(value)
return if (value == 0.0) { return when {
"0" value == 0.0 -> "0"
} else if (absVal < 0.01 || absVal >= 1e5) { absVal < 0.01 || absVal >= 1e5 -> formatScientific(value, decimals)
"%.${decimals}e".format(value) else -> formatFixed(value, decimals)
} else {
"%.${decimals}f".format(value)
} }
} }
private fun formatFixed(value: Double, decimals: Int): String {
val negative = value < 0.0
val absVal = kotlin.math.abs(value)
val factor = pow10(decimals)
val rounded = kotlin.math.round(absVal * factor)
val whole = (rounded / factor).toLong()
val frac = (rounded - whole * factor).toLong()
val sign = if (negative && (whole != 0L || frac != 0L)) "-" else ""
return if (decimals <= 0) {
"$sign$whole"
} else {
val fracStr = frac.toString().padStart(decimals, '0')
"$sign$whole.$fracStr"
}
}
private fun formatScientific(value: Double, decimals: Int): String {
if (value == 0.0) return formatFixed(0.0, decimals) + "e+00"
val negative = value < 0.0
val absVal = kotlin.math.abs(value)
val exp = kotlin.math.floor(kotlin.math.log10(absVal)).toInt()
val mantissa = absVal / pow10(exp)
val mantStr = formatFixed(mantissa, decimals)
val sign = if (negative) "-" else ""
val expSign = if (exp >= 0) "+" else "-"
val expAbs = kotlin.math.abs(exp).toString().padStart(2, '0')
return "$sign${mantStr}e$expSign$expAbs"
}
private fun pow10(n: Int): Double {
if (n == 0) return 1.0
var r = 1.0
val absN = kotlin.math.abs(n)
repeat(absN) { r *= 10.0 }
return if (n < 0) 1.0 / r else r
}
private fun niceInterval(range: Double, targetTickCount: Int): Double { private fun niceInterval(range: Double, targetTickCount: Int): Double {
if (range <= 0) return 1.0 if (range <= 0) return 1.0
val rough = range / targetTickCount val rough = range / targetTickCount

View file

@ -2,4 +2,10 @@ package dev.dtrentin.chart
import platform.Foundation.NSDate import platform.Foundation.NSDate
internal actual fun currentTimeMs(): Long = (NSDate.date().timeIntervalSince1970 * 1000).toLong() private const val REFERENCE_DATE_OFFSET_SEC = 978_307_200.0
internal actual fun currentTimeMs(): Long {
val date = NSDate()
val secsSince1970 = date.timeIntervalSinceReferenceDate + REFERENCE_DATE_OFFSET_SEC
return (secsSince1970 * 1000).toLong()
}

View file

@ -1,4 +1,3 @@
org.gradle.java.home=/home/dtrentin/.local/share/JetBrains/Toolbox/apps/android-studio/jbr
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.caching=true org.gradle.caching=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC