macosArm64 target cannot be enabled until negentropy-kmp publishes a
macosArm64 artifact — commented out with explanation. Reverted run_all.sh
back to Linux-only for K/Native benchmark.
Added Kotlin/Native toolchain dependencies (GCC sysroot, LLDB, LLVM,
libffi) to session-start.sh so K/N compilation works in Claude Code
remote environments where Gradle's own downloader fails through the
proxy.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
- Declare macosX64() and macosArm64() KMP targets in build.gradle.kts
- Wire macosMain/macosTest source sets through appleMain/appleTest
- Move Secp256k1NativeBenchmark from linuxX64Test to nativeTest so it
runs on all native targets (Linux, macOS, iOS)
- Use platform() for dynamic labels instead of hardcoded "linuxX64"
- Update run_all.sh to pick the correct native target per OS/arch
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
Three issues fixed:
- Replace fragile -newer comparison against the script file (breaks
after any script edit) with a timestamp file created just before the
Gradle task runs.
- Search connected_android_test_additional_output for pulled benchmark
JSON (where AndroidX Benchmark actually writes results via Gradle).
- Extract benchmark data from XML via CDATA parsing instead of dumping
raw XML, consistent with the JVM and K/Native sections.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
The ACINQ secp256k1-kmp-jni dylib ships with a relative LC_ID_DYLIB
("build/darwin/libsecp256k1-jni.dylib"). macOS dyld resolves this
literally, ignoring the -Wl,-rpath passed at link time, causing an
immediate abort at launch. Using install_name_tool to rewrite the
install name to @rpath/libsecp256k1-jni.dylib lets dyld find the
library via the rpath we already set.
https://claude.ai/code/session_01WSNE6QKiYM2ZutQD2UihCW
- Send hangup/reject to peer when WebRTC init or PeerConnection creation
fails, so remote phone stops ringing instead of timing out after 60s
- Throw on null PeerConnection from factory to fail fast instead of
silently no-oping all subsequent WebRTC operations
- Start foreground service during IncomingCall to protect ringtone
playback from being killed on Android 14+
- Make cleanup() idempotent with AtomicBoolean guard to prevent double
disposal when Ended state and ViewModel.onCleared race
- Replace mutableMapOf with ConcurrentHashMap for videoSenders accessed
from UI and WebRTC callback threads
- Add @Volatile to peerConnection, videoPausedByProximity, and
foregroundServiceStarted for cross-thread visibility
- Capture peerConnection into local variable in dispose() to prevent
TOCTOU race between close() and null assignment
- Replace leaked MainScope() in CallNotificationReceiver with structured
CoroutineScope that is cancelled after work completes
- Remove self-wraps in group answer/reject to avoid wasting bandwidth
sending encrypted messages to ourselves
- Move startTimeout inside stateMutex in initiateCall for consistency
https://claude.ai/code/session_017HrFJNxD6zrGwiZ3s69xTh
Bug fixes:
- Fix RemoteVideoMonitor killing group monitor job when primary track switches
- Add mutex protection to CallManager.initiateCall() to prevent state races
- Fix ICE restart offer never being sent to remote peer (was immediately
replaced by a second offer from onRenegotiationNeeded)
- Fix duplicate duration timer in PiP connected call UI
- Fix error snackbar dismiss button not clearing the error
- Make PeerSessionManager thread-safe with synchronized blocks (accessed
from WebRTC native threads and coroutine dispatchers concurrently)
- Make CallManager event handlers private (only called from onSignalingEvent)
Improvements:
- Replace fragile ICE candidate regex parsing with kotlinx.serialization JSON
- Respect DND/silent mode: only ring in NORMAL mode, only vibrate in VIBRATE
- Signal camera-off to remote peer by removing video track sender (instead
of sending frozen/black frame)
- Clear CallSessionBridge on AccountViewModel.onCleared() to prevent stale
references on account switch
- Custom TURN servers now replace defaults (instead of appending) so
credentials can be rotated without an app update
New features:
- Front/back camera switch button (visible when video is enabled)
- Network transition handling: ConnectivityManager.NetworkCallback triggers
ICE restart on all peers when network changes (WiFi/cellular handoff)
https://claude.ai/code/session_01JHn7skAibTrkVqsoWutgYe
The foreground service was only started after onPeerConnected, leaving
the Offering/Connecting phases unprotected. If the user backgrounded
the app during connecting, Android 14+ could block the later
startForegroundService() call, killing the call.
Changes:
- Start foreground service on Offering state (user just tapped call
button, so app is guaranteed to be in foreground)
- Update notification text on Connecting/Connected transitions
- Add ACTION_UPDATE to CallForegroundService to change notification
without restarting the service
- onPeerConnected now uses ensureForegroundService() as a safety net
https://claude.ai/code/session_01F5RF2yzngiMr1v2gr7f1GP
Bug fixes:
- Fix invitePeer() bypassing CallManager state tracking, causing
invited peers to not appear in pendingPeerPubKeys
- Remove 10-minute proximity wake lock timeout so it lasts the
full call duration (released on cleanup)
- Send hangup to peers on caller timeout so callees stop ringing
immediately instead of waiting for their own 60s timeout
- Remove duplicate cleanup() call on Ended→Idle transition
New feature:
- Add Call Settings screen (TURN servers + video quality)
- Users can configure custom TURN servers for restrictive networks
- Default STUN/TURN servers are always active and displayed
- Video resolution options: 480p, 720p (default), 1080p
- Configurable max video bitrate: 750kbps, 1.5Mbps, 3Mbps
- Settings wired into IceServerConfig and CallMediaManager
https://claude.ai/code/session_01F5RF2yzngiMr1v2gr7f1GP
On Apple (iOS/macOS) and Linux targets, LargeCache.forEach() iterates
the underlying map directly. When another coroutine modifies the map
during iteration (e.g., NostrClient.syncFilters running while
subscriptions are added), a ConcurrentModificationException is thrown.
On JVM/Android this is not an issue because ConcurrentSkipListMap
handles concurrent iteration safely. On Kotlin/Native (iOS), this
exception is fatal — K/N calls abort() for unhandled exceptions,
crashing the app immediately after account creation when relays
connect and subscriptions start syncing.
Fix: call .entries.toList() before iterating to create a snapshot,
matching the JVM behavior where concurrent modifications during
iteration are tolerated.
Benchmarked two approaches for hardware 128-bit multiply on K/N:
1. Full mulWide via C interop (memScoped + allocArray + fe4_mul_reduce):
FieldP.mul: 44ns → 116ns (2.6x SLOWER — copy/marshal overhead)
2. Per-call umulh via C interop (fe4_umulh, 20 calls per field mul):
FieldP.mul: 44ns → 331ns (7.5x SLOWER — ~15ns bridge per call)
Conclusion: K/N cinterop bridge adds ~15ns per call, making fine-grained
C interop unviable for the multiply-high hot path (20+ calls per field op).
The pure-Kotlin fused approach (4 IMUL per 128-bit product) remains optimal
at 44ns/op until K/N supports hardware MUL natively.
Updated FieldMulPlatform.native.kt docs with benchmarked rationale.
Fixed remaining LongArray references in native benchmark test.
https://claude.ai/code/session_01Sxi6Gpxbstuj3Y8TBY7XrU
Critical fixes:
- Fix PSK/ExternalInit proposals by Reference dropped from key schedule:
processCommit now collects ALL resolved proposals (inline + by-reference)
into resolvedProposals list used for PSK and ExternalInit computation
- Fix decrypt() missing blank-leaf membership check: validate sender leaf
is non-null (occupied) before proceeding with decryption
High fixes:
- Fix MlsGroupManager.decrypt() now mutex-protected to prevent concurrent
SecretTree ratchet corruption and potential nonce reuse
Medium fixes:
- Fix externalJoin: verify GroupInfo signature before trusting tree/keys
- Fix parentHash verification: COMMIT leaf nodes must have non-empty
parentHash (no longer silently skipped)
- Fix proposal application order: Updates/Removes applied before Adds
per RFC 9420 §12.4.2 (frees blank slots before reuse)
- Add encryption key uniqueness check in RatchetTree.addLeaf() per §7.3
- Add LeafNode capabilities validation: verify version and ciphersuite
support in applyProposalAdd per §12.1.1
- Remove redundant confirmation tag recomputation in processCommit
https://claude.ai/code/session_017SjKXS4Vpu4xRg9zHTgpmC
Move foregroundServiceStarted flag check and onPeerDisconnected()
inside scope.launch to avoid accessing main-thread-only state from
WebRTC's internal observer thread.
https://claude.ai/code/session_01DE9BUAuLJSwT3jq7S53NJ6
Full migration of the secp256k1 library from LongArray(4)/LongArray(8)
to Fe4/Wide8 struct types with @JvmField named Long fields. This
eliminates all array bounds checks from the hot path.
Files migrated (13 source + 7 test + 2 benchmark):
- U256.kt, FieldP.kt, ScalarN.kt, Glv.kt, ECPoint.kt
- FieldMulPlatform.kt (expect + 3 actuals), FieldMulFused.kt
- PointTypes.kt (MutablePoint, AffinePoint, PointScratch)
- KeyCodec.kt, Secp256k1.kt
- All test files and benchmarks
Bytecode impact:
Before: 464 laload/lastore (bounds-checked) in core arithmetic
After: 0 laload/lastore, all getfield/putfield (no checks)
The public API (Secp256k1 object) is unchanged - it still accepts
and returns ByteArray. Fe4 conversion happens at the API boundary
via U256.fromBytes()/U256.toBytes().
All secp256k1 unit tests pass on JVM.
https://claude.ai/code/session_01Sxi6Gpxbstuj3Y8TBY7XrU
- Fix enableVideo() not restarting camera after disable/re-enable
- Add hangup action to foreground service notification with tap-to-open
- Register BluetoothSco receiver with RECEIVER_NOT_EXPORTED flag
- Replace GlobalScope with lifecycleScope+NonCancellable in CallActivity
- Replace GlobalScope with goAsync()+MainScope in CallNotificationReceiver
- Reduce proximity WakeLock timeout from 1 hour to 10 minutes
- Add try-catch to CallMediaManager.initialize() to prevent EglBase leak
- Add ICE restart attempt before giving up on FAILED state
- Add VideoRenderer update block to handle track reference changes
https://claude.ai/code/session_01DE9BUAuLJSwT3jq7S53NJ6
The script was hardcoded for Linux (searching for .so files and
linux-x86_64 paths). Add platform detection via uname so it finds
the correct native library on macOS (darwin .dylib) and Linux
(aarch64 and x86_64). Skip K/Native benchmark on non-Linux since
only linuxX64 target exists.
https://claude.ai/code/session_01JZbyrS9xZEtJ9Y4yfsmnz1
run_all.sh runs all secp256k1 benchmarks and produces a formatted
comparison table. Runs C native, Kotlin/Native, JVM (always), and
Android (only if device/emulator connected via adb).
Output includes:
- ops/sec with comma-separated numbers
- libsecp256k1 vs Quartz column headers
- verifySchnorrFast and signSchnorr (cached pk) as Quartz-only rows
- Ratio table: C vs K/Native, JNI vs JVM Kotlin (apples-to-apples)
- Android column and ratios when device is connected
Run from repo root: ./quartz/benchmarks/run_all.sh
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Standalone C program that links against the ACINQ secp256k1-kmp-jni
.so to benchmark raw C libsecp256k1 performance without any JVM, JNI,
or ART overhead. Uses the same test vectors as the Kotlin benchmarks.
Useful as a baseline when comparing Kotlin/Native or Android results
against the C library on the same hardware.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add optional PointScratch parameter to ECPoint.mul and ECPoint.mulG
(default to scratch.get() for backward compat). All callers in
Secp256k1.kt now pass their already-fetched scratch through.
From the ECDH trace: ECPoint.mul was calling scratch.get() (ThreadLocal)
redundantly — the caller already had the scratch. On ART, each
ThreadLocal.get costs ~6µs (hash table probe), and ECDH had 10 calls
totaling 64µs.
With this change, all hot-path EC operations (verify, sign, ECDH,
pubkey create, tweak mul) fetch the scratch once at the entry point
and pass it through the entire call chain.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Replace every non-inline uLt() call with uLtInline() across FieldP.kt,
U256.kt, and ScalarN.kt. The expect/actual uLt() can't be inline
(KMP limitation), costing ~84ns per call on ART as a real function
dispatch. From the trace: 12,394 uLt calls × 84ns = 1.035ms per
verify (1.2% of total).
uLtInline uses the same XOR-with-MIN_VALUE trick but as a package-level
inline function — zero dispatch overhead.
Also inline isInfinity() body directly: was delegating to U256.isZero()
(double dispatch), now computes (z[0] or z[1] or z[2] or z[3]) == 0L
directly. 190 calls × 347ns = 66µs saved.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Pass PointScratch through mulDoubleG instead of re-fetching from
ThreadLocal. The verify path was calling ThreadLocal.get() 2x:
once in verifySchnorrFast and again in mulDoubleG. On ART, each
ThreadLocal.get costs ~41µs (hash table probe), totaling ~83µs
per verify (~1% of total).
mulDoubleG now accepts an optional PointScratch parameter
(defaults to scratch.get() for backward compat). The verify
path passes its already-fetched scratch through.
Also fix all remaining LongArray copyInto calls with default params:
- MutablePoint.copyFrom: 3 calls per copy (x, y, z)
- MutablePoint.setAffine: 2 calls (x, y)
- mulDoubleG P-table build: 2 calls per table entry
- mul P-table build: 2 calls per table entry
- batchToAffine: 2 calls
- U256.copyInto: was delegating with defaults
Each copyInto$default adds a bitmask check + 3 branches + arraylength
per call. With ~13 LongArray copies per verify, this eliminates ~52
extra branch instructions from the hot path.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Replace U256.fromBytes (allocates LongArray(4)) with
U256.fromBytesInto (writes to pre-allocated scratch) across
all key operations:
- pubkeyCreate: scalar → sc.scalarTmp1 (safe: mulG doesn't use it)
- signSchnorrWithPubKey: d0 → sc.zInv (safe: signSchnorrInternal
doesn't use it; d0 is read at lines 322-324 and 391 before zInv
is needed)
- signSchnorrWithXOnlyPubKey: same pattern with sc.zInv
- pubKeyTweakMul: scalar → sc.scalarTmp1 (safe: ECPoint.mul
doesn't use scalarTmp fields)
- ecdhXOnly: k → sc.scalarTmp1 (same)
- privKeyTweakAdd: already fixed in previous commit
signSchnorr keeps its allocation because d0 must survive through
mulG (which destroys splitK*) AND signSchnorrInternal (which uses
scalarTmp*). The single LongArray(4) alloc is negligible vs mulG
cost (~100μs).
Each eliminated allocation saves ~10-20ns of GC pressure on ART
and ~5ns on K/Native. For signSchnorrWithPubKey (the cached-pk
fast path), this removes the only unnecessary allocation.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Use thread-local scratch LongArrays instead of allocating 2
intermediate LongArray(4) via U256.fromBytes. The old path did:
fromBytes(seckey) → alloc LongArray(4)
fromBytes(tweak) → alloc LongArray(4)
ScalarN.add(a, b) → alloc LongArray(4)
U256.toBytes(r) → alloc ByteArray(32)
= 4 heap allocations
New path uses pre-allocated scratch from PointScratch:
fromBytesInto(scratch, seckey) → zero alloc
fromBytesInto(scratch, tweak) → zero alloc
ScalarN.addTo(scratch, a, b) → zero alloc
U256.toBytes(r) → 1 alloc (unavoidable, return value)
K/Native: 163 → 88 ns (-46%, from 6.3x to 3.4x vs C)
JVM: now faster than native C via JNI (Kotlin 6.5M ops/s vs C 4.8M ops/s)
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add fieldMulReduceFused and fieldSqrReduceFused that compute both lo
and hi of each 64×64→128 multiply from 4 shared sub-products.
Previous: lo = a * b (1 hardware mul) + hi = umulh(a,b) (4 imul) = 5
Fused: 4 shared sub-products compute both lo and hi = 4 multiplies
The inline lambda consumer pattern avoids allocation:
mulFull(a, b) { lo, hi -> ... } // everything inlined at call site
K/Native benchmark improvement (linuxX64):
verifySchnorrFast: 101,637 → 96,699 ns (-5%)
signSchnorr(cached): 50,206 → 45,037 ns (-10%)
compressedPubKeyFor: 40,906 → 37,075 ns (-9%)
Native instruction count: 95 → 79 multiplies per field mul
Android keeps the unfused path because ART's single-cycle MUL for
a*b is faster than manually constructing lo from sub-products.
The fused approach saves 1 multiply but adds ~5 shift/and/or ops —
net regression on ART (80,756 vs 74,898 ns for verify).
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Remove crypto-intrinsics module and API-tiered dispatch files.
Revert to the pure-Kotlin fallback for Android field multiply.
Three approaches to reach hardware UMULH were tested on Pixel 8
(Android 16, API 36) and all failed:
1. Direct Math.unsignedMultiplyHigh from Kotlin:
D8 replaces with pure-Java backport when app minSdk=26 < 35.
2. MethodHandle.invokeExact via Java helper module:
Bytecode correct (invoke-polymorphic JJ→J, zero boxing), but
ART can't inline invoke-polymorphic. 25ns/call → 2.2x regression.
3. Full fieldMulReduce in Java, module with minSdk=35:
D8 runs at APP level with app's minSdk=26, not library's minSdk=35.
Trace confirmed: FieldMulIntrinsic$$ExternalSyntheticBackport0.m
still generated. Plus Long.compareUnsigned (282ns/call) is slower
than Kotlin's inlined XOR trick (~0ns).
The Kotlin inline+crossinline pattern remains optimal for ART:
- unsignedMultiplyHighFallback inlined at each call site
- uLtInline uses XOR+compare (no method call)
- Fused function fits ART's inlining budget
UMULH on Android requires raising app minSdk to 35.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR
Add 3-file dispatch structure for fieldMulReduce on Android:
- FieldMulApi35: placeholder for Math.unsignedMultiplyHigh (API 35+)
- FieldMulApi31: placeholder for Math.multiplyHigh (API 31-34)
- Fallback: pure-Kotlin 4-imul (API <31, unchanged)
All three currently use the pure-Kotlin fallback because both paths
to the hardware UMULH intrinsic are blocked:
1. Direct Math.unsignedMultiplyHigh call: D8 replaces with synthetic
backport (ExternalSyntheticBackport0.m) when minSdk=26 < 35.
Verified via dexdump: backport is pure-Java 4-imul, never reaches
hardware UMULH even on API 36 devices.
2. MethodHandle.invokeExact: Kotlin compiles as regular invokevirtual
with Object[] boxing (3 allocs per call), not type-exact
invoke-polymorphic (JJ)J. Only Java's javac has @PolymorphicSignature
support. KMP androidMain doesn't support Java sources.
TO UNLOCK: Add a Java helper class (MulHighInvoker.java) in a separate
Android library module that calls MethodHandle.invokeExact(long, long)
with zero boxing. This produces invoke-polymorphic that D8 cannot
desugar and ART intrinsifies to UMULH on ARM64.
https://claude.ai/code/session_015CtM5k88rF7WFgX8o2AGNR