From c9b6df5fa2756f3983cc3f6bc9689d479fb1a385 Mon Sep 17 00:00:00 2001 From: Wellington Pereira Date: Wed, 8 Jul 2026 20:27:09 -0300 Subject: [PATCH 1/7] Shared sink registry --- .../crashkios/core/CrashSinkRegistry.kt | 33 ++++ .../crashkios/core/ThrowableNSException.kt | 169 ++++++++++++++++++ .../core/ThrowableNSExceptionTest.kt | 44 +++++ 3 files changed, 246 insertions(+) create mode 100644 core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt create mode 100644 core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThrowableNSException.kt create mode 100644 core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt diff --git a/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt new file mode 100644 index 0000000..9af29d2 --- /dev/null +++ b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt @@ -0,0 +1,33 @@ +package co.touchlab.crashkios.core + +import kotlin.concurrent.AtomicInt +import kotlin.concurrent.AtomicReference + +/** + * Registration point shared by the CrashKiOS SDK modules: holds the Swift-implemented + * sink and installs the unhandled-exception hook exactly once. + * + * Internal machinery for the crashlytics/bugsnag modules — not API for apps. + */ +public class CrashSinkRegistry(private val name: String) { + private val sinkRef = AtomicReference(null) + private val hookInstalled = AtomicInt(0) + + /** + * Returns the registered sink, or fails loud: a crash-reporting implementation + * constructed without a sink would silently lose every event, which is worse + * than crashing at startup with a clear message. + */ + public fun requireSink(): T = sinkRef.value ?: error( + "CrashKiOS: no $name sink registered. Call register${name}Sink() from Swift at " + + "startup (after the $name SDK is initialized) before enabling CrashKiOS from Kotlin.", + ) + + public fun register(sink: T, fatalHook: (Throwable) -> Unit) { + sinkRef.value = sink + // Guard: wrapping twice would chain the hook onto itself and double-report fatals. + if (hookInstalled.compareAndSet(0, 1)) { + wrapUnhandledExceptionHook(fatalHook) + } + } +} diff --git a/core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThrowableNSException.kt b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThrowableNSException.kt new file mode 100644 index 0000000..305e027 --- /dev/null +++ b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThrowableNSException.kt @@ -0,0 +1,169 @@ +/* + * Vendored from NSExceptionKt v0.1.10 (https://github.com/rickclephas/NSExceptionKt), + * adapted for the Kotlin 2.x memory model (freeze() removed) and the CrashKiOS package. + * + * MIT License + * Copyright (c) 2022 Rick Clephas + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package co.touchlab.crashkios.core + +import kotlin.concurrent.AtomicReference +import kotlin.experimental.ExperimentalNativeApi +import kotlin.native.ReportUnhandledExceptionHook +import kotlin.native.setUnhandledExceptionHook +import kotlin.native.terminateWithUnhandledException +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.UnsafeNumber +import kotlinx.cinterop.convert +import platform.Foundation.NSException +import platform.Foundation.NSNumber +import platform.darwin.NSUInteger + +/** + * Returns a [NSException] representing `this` [Throwable]. + * If [appendCausedBy] is `true` then the name, message and stack trace + * of the [causes][Throwable.cause] will be appended, else causes are ignored. + */ +@OptIn(UnsafeNumber::class, ExperimentalForeignApi::class) +public fun Throwable.asNSException(appendCausedBy: Boolean = false): NSException { + val returnAddresses = getFilteredStackTraceAddresses().let { addresses -> + if (!appendCausedBy) return@let addresses + addresses.toMutableList().apply { + for (cause in causes) { + addAll(cause.getFilteredStackTraceAddresses(true, addresses)) + } + } + }.map { + @Suppress("RemoveExplicitTypeArguments") + NSNumber(unsignedInteger = it.convert()) + } + return ThrowableNSException(throwableName, getReason(appendCausedBy), returnAddresses) +} + +/** + * Returns the qualifiedName or simpleName of `this` throwable's class, + * or "Throwable" if both are `null`. + * + * Public so the SDK modules report the same name on the handled and fatal paths. + */ +public val Throwable.throwableName: String + get() = this::class.qualifiedName ?: this::class.simpleName ?: "Throwable" + +/** + * Returns the [message][Throwable.message] of this throwable. + * If [appendCausedBy] is `true` then caused by lines with the format + * "Caused by: $name: $message" will be appended. + */ +internal fun Throwable.getReason(appendCausedBy: Boolean = false): String? { + if (!appendCausedBy) return message + return buildString { + message?.let(::append) + for (cause in causes) { + if (isNotEmpty()) appendLine() + append("Caused by: ") + append(cause.throwableName) + cause.message?.let { append(": $it") } + } + }.takeIf { it.isNotEmpty() } +} + +internal class ThrowableNSException(name: String, reason: String?, private val returnAddresses: List) : + NSException(name, reason, null) { + override fun callStackReturnAddresses(): List = returnAddresses +} + +/** + * Returns a list with all the [causes][Throwable.cause]. + * The first element will be the cause, the second the cause of the cause, etc. + * This function stops once a reference cycle is detected. + */ +public val Throwable.causes: List get() = buildList { + val causes = mutableSetOf() + var cause = cause + while (cause != null && causes.add(cause)) { + add(cause) + cause = cause.cause + } +} + +/** + * Returns a list of stack trace addresses representing + * the stack trace of the constructor call to `this` [Throwable]. + * @param keepLastInit `true` to preserve the last constructor call, `false` to drop all constructor calls. + * @param commonAddresses a list of addresses used to drop the last common addresses. + * @see kotlin.getStackTraceAddresses + */ +@OptIn(ExperimentalNativeApi::class) +public fun Throwable.getFilteredStackTraceAddresses(keepLastInit: Boolean = false, commonAddresses: List = emptyList()): List = + getStackTraceAddresses().dropInitAddresses( + qualifiedClassName = this::class.qualifiedName ?: Throwable::class.qualifiedName!!, + stackTrace = getStackTrace(), + keepLast = keepLastInit, + ).dropCommonAddresses(commonAddresses) + +/** + * Returns a list containing all addresses except for the first addresses + * matching the constructor call of the [qualifiedClassName]. + * If [keepLast] is `true` the last constructor call won't be dropped. + */ +internal fun List.dropInitAddresses(qualifiedClassName: String, stackTrace: Array, keepLast: Boolean = false): List { + val exceptionInit = "kfun:$qualifiedClassName#" + var dropCount = 0 + var foundInit = false + for (i in stackTrace.indices) { + if (stackTrace[i].contains(exceptionInit)) { + foundInit = true + } else if (foundInit) { + dropCount = i + break + } + } + if (keepLast) dropCount-- + return drop(kotlin.math.max(0, dropCount)) +} + +/** + * Returns a list containing all addresses except for the last addresses that match with the [commonAddresses]. + */ +internal fun List.dropCommonAddresses(commonAddresses: List): List { + var i = commonAddresses.size + if (i == 0) return this + // `> 0`, not `>= 0`: at i == 0 the guard must stop the iteration instead of + // decrementing past the start and reading commonAddresses[-1] (upstream fix). + return dropLastWhile { + i-- > 0 && commonAddresses[i] == it + } +} + +/** + * Wraps the unhandled exception hook such that the provided [hook] is invoked + * before the currently set unhandled exception hook is invoked. + * Note: once the unhandled exception hook returns the program will be terminated. + * @see setUnhandledExceptionHook + * @see terminateWithUnhandledException + */ +@OptIn(ExperimentalNativeApi::class) +public fun wrapUnhandledExceptionHook(hook: (Throwable) -> Unit) { + val prevHook = AtomicReference(null) + val wrappedHook: ReportUnhandledExceptionHook = { + hook(it) + prevHook.value?.invoke(it) + terminateWithUnhandledException(it) + } + prevHook.value = setUnhandledExceptionHook(wrappedHook) +} diff --git a/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt b/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt new file mode 100644 index 0000000..7e4a18e --- /dev/null +++ b/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt @@ -0,0 +1,44 @@ +package co.touchlab.crashkios.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ThrowableNSExceptionTest { + @Test + fun dropCommonAddressesDropsMatchingTail() { + assertEquals(listOf(9L), listOf(9L, 5L, 7L).dropCommonAddresses(listOf(5L, 7L))) + } + + @Test + fun dropCommonAddressesSurvivesReceiverLongerThanCommons() { + // Regression: the pre-fix guard (i-- >= 0) read commonAddresses[-1] here. + assertEquals(listOf(1L, 9L), listOf(1L, 9L, 5L, 7L).dropCommonAddresses(listOf(5L, 7L))) + } + + @Test + fun dropCommonAddressesFullMatch() { + assertEquals(emptyList(), listOf(5L, 7L).dropCommonAddresses(listOf(5L, 7L))) + } + + @Test + fun dropCommonAddressesNoMatchAndEmptyCommons() { + assertEquals(listOf(1L, 2L), listOf(1L, 2L).dropCommonAddresses(listOf(9L))) + assertEquals(listOf(1L, 2L), listOf(1L, 2L).dropCommonAddresses(emptyList())) + } + + @Test + fun asNSExceptionWithConstructorBuiltCauseDoesNotThrow() { + // End-to-end regression for the crash-in-the-crash-handler path. + class WrappedError(msg: String) : Exception(msg, RuntimeException("io")) + val exception = WrappedError("outer").asNSException(appendCausedBy = true) + assertEquals("Caused by", exception.reason?.substringAfter("outer\n")?.substringBefore(":")) + } + + @Test + fun requireSinkFailsLoudWhenUnregistered() { + val registry = CrashSinkRegistry("Crashlytics") + val error = assertFailsWith { registry.requireSink() } + assertEquals(true, error.message?.contains("registerCrashlyticsSink")) + } +} From 5cade0ae5282fb205ce8a6337db12125c11ea49e Mon Sep 17 00:00:00 2001 From: Wellington Pereira Date: Wed, 8 Jul 2026 20:29:05 -0300 Subject: [PATCH 2/7] Add CrashKiOS Swift package --- .gitignore | 2 + Package.swift | 58 ++++++++++ Sources/CrashKiOSBugsnag/BugsnagSink.swift | 102 ++++++++++++++++++ .../CrashKiOSBugsnagObjC.m | 1 + .../include/CrashKiOSBugsnagSink.h | 41 +++++++ .../CrashlyticsSink.swift | 46 ++++++++ .../CrashKiOSCrashlyticsObjC.m | 1 + .../include/CrashKiOSCrashlyticsSink.h | 37 +++++++ .../include/FIRCLSException.h | 30 ++++++ 9 files changed, 318 insertions(+) create mode 100644 Package.swift create mode 100644 Sources/CrashKiOSBugsnag/BugsnagSink.swift create mode 100644 Sources/CrashKiOSBugsnagObjC/CrashKiOSBugsnagObjC.m create mode 100644 Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h create mode 100644 Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift create mode 100644 Sources/CrashKiOSCrashlyticsObjC/CrashKiOSCrashlyticsObjC.m create mode 100644 Sources/CrashKiOSCrashlyticsObjC/include/CrashKiOSCrashlyticsSink.h create mode 100644 Sources/CrashKiOSCrashlyticsObjC/include/FIRCLSException.h diff --git a/.gitignore b/.gitignore index 0c4acc6..4440276 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,5 @@ fastlane/test_output buildboth.sh Pods +.build/ +Package.resolved diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6d63deb --- /dev/null +++ b/Package.swift @@ -0,0 +1,58 @@ +// swift-tools-version: 5.5 +// CrashKiOS Swift sinks — live in the app's build graph, where the crash-SDK +// binaries are already linked. See docs-plans/crashkios-linking-remediation-plan.md. + +import PackageDescription + +let package = Package( + name: "CrashKiOS", + platforms: [.iOS(.v15), .macOS(.v10_15), .tvOS(.v15), .watchOS(.v7)], + products: [ + .library( + name: "CrashKiOSCrashlytics", + targets: ["CrashKiOSCrashlytics"] + ), + .library( + name: "CrashKiOSBugsnag", + targets: ["CrashKiOSBugsnag"] + ), + ], + dependencies: [ + .package( + url: "https://github.com/firebase/firebase-ios-sdk.git", + "11.0.0"..<"13.0.0" + ), + .package( + url: "https://github.com/bugsnag/bugsnag-cocoa.git", + "6.22.1"..<"7.0.0" + ), + ], + targets: [ + // Header-only targets: they own the sink protocols (cinterop'd by the Kotlin + // modules) so Swift sinks compile without importing the app's Kotlin framework. + .target( + name: "CrashKiOSCrashlyticsObjC", + path: "Sources/CrashKiOSCrashlyticsObjC" + ), + .target( + name: "CrashKiOSCrashlytics", + dependencies: [ + .target(name: "CrashKiOSCrashlyticsObjC"), + .product(name: "FirebaseCrashlytics", package: "firebase-ios-sdk"), + ], + path: "Sources/CrashKiOSCrashlytics" + ), + .target( + name: "CrashKiOSBugsnagObjC", + path: "Sources/CrashKiOSBugsnagObjC" + ), + .target( + name: "CrashKiOSBugsnag", + dependencies: [ + .target(name: "CrashKiOSBugsnagObjC"), + .product(name: "Bugsnag", package: "bugsnag-cocoa"), + ], + path: "Sources/CrashKiOSBugsnag" + ), + ] +) diff --git a/Sources/CrashKiOSBugsnag/BugsnagSink.swift b/Sources/CrashKiOSBugsnag/BugsnagSink.swift new file mode 100644 index 0000000..def14d0 --- /dev/null +++ b/Sources/CrashKiOSBugsnag/BugsnagSink.swift @@ -0,0 +1,102 @@ +import Foundation +import ObjectiveC +import CrashKiOSBugsnagObjC +import Bugsnag + +/// Feature flag used to mark the Kotlin termination crash. +/// Public so custom `CrashKiOSBugsnagSink` implementations can honor the same contract. +public let kotlinCrashedFeatureFlag = "crashkios.kotlin_crashed" + +/// Prepares `config` for Kotlin crash handling: installs the OnSendError filter that +/// suppresses the duplicate termination crash following a recorded Kotlin fatal, and +/// works around Bugsnag 6.26.2+ refusing to persist synthetic unhandled events. +/// +/// Custom sink implementations MUST call this before `Bugsnag.start(with: config)` — +/// otherwise every fatal Kotlin exception is reported twice. +public func configureBugsnagForKotlin(_ config: BugsnagConfiguration) { + overrideOriginalUnhandledValue() + config.addOnSendError { event in + // Drop Bugsnag's own report of the abort that terminates the app after a + // Kotlin fatal was already recorded (flagged via markFatalCrashRecorded). + !event.unhandled || !event.featureFlags.contains { $0.name == kotlinCrashedFeatureFlag } + } + config.clearFeatureFlag(name: kotlinCrashedFeatureFlag) +} + +/// Reference `CrashKiOSBugsnagSink` implementation. +/// +/// Start Bugsnag through this class so the configuration is prepared for Kotlin +/// crash handling, then register the returned sink with the Kotlin side: +/// ```swift +/// let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) +/// BugsnagKt.registerBugsnagSink(sink: sink) +/// ``` +/// (Add `export("co.touchlab.crashkios:bugsnag")` to the Kotlin framework for clean names.) +public final class BugsnagSink: NSObject, CrashKiOSBugsnagSink { + + /// Configures `config` via `configureBugsnagForKotlin(_:)`, starts Bugsnag, + /// and returns the sink. + public static func start(_ config: BugsnagConfiguration) -> BugsnagSink { + configureBugsnagForKotlin(config) + Bugsnag.start(with: config) + return BugsnagSink() + } + + public func leaveBreadcrumb(_ message: String) { + Bugsnag.leaveBreadcrumb(withMessage: message) + } + + public func notify(with exceptions: [NSException], handled: Bool) { + guard let exception = exceptions.first else { return } + // Notify persists unhandled events, so the caller can safely terminate afterwards. + // https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/Client/BugsnagClient.m#L744 + Bugsnag.notify(exception) { event in + if handled { + event.severity = .warning + } else { + event.unhandled = true + event.severity = .error + } + event.errors += exceptions.dropFirst().map(BugsnagError.init) + return true + } + } + + public func addMetadata(_ value: Any, key: String, section: String) { + Bugsnag.addMetadata(value, key: key, section: section) + } + + public func markFatalCrashRecorded() { + // Called only by the Kotlin unhandled-exception hook, right before termination — + // never on direct sendFatalException() calls, which would poison the session and + // make the OnSendError filter discard later genuine crashes. + Bugsnag.addFeatureFlag(name: kotlinCrashedFeatureFlag) + } +} + +/// In Bugsnag 6.26.2+ the `originalUnhandledValue` property prevents our synthetic +/// unhandled exceptions from being stored to disk; alias it to `unhandled`. +/// https://github.com/bugsnag/bugsnag-cocoa/pull/1549 +private func overrideOriginalUnhandledValue() { + guard let handledStateClass = NSClassFromString("BugsnagHandledState"), + let originalMethod = class_getInstanceMethod(handledStateClass, NSSelectorFromString("originalUnhandledValue")), + let method = class_getInstanceMethod(handledStateClass, NSSelectorFromString("unhandled")) + else { + // Loud, not silent: if Bugsnag renames these internals, fatal Kotlin crashes + // would silently stop reaching the dashboard (>=6.26.2 refuses to persist them). + assertionFailure("CrashKiOS: BugsnagHandledState internals changed — Kotlin fatal crashes may not be persisted. Update CrashKiOSBugsnag.") + return + } + method_setImplementation(originalMethod, method_getImplementation(method)) +} + +private extension BugsnagError { + /// Creates a BugsnagError from a (Kotlin-synthesized) NSException. + convenience init(_ exception: NSException) { + self.init() + errorClass = exception.name.rawValue + errorMessage = exception.reason + stacktrace = BugsnagStackframe.stackframes(withCallStackReturnAddresses: exception.callStackReturnAddresses) + type = .cocoa + } +} diff --git a/Sources/CrashKiOSBugsnagObjC/CrashKiOSBugsnagObjC.m b/Sources/CrashKiOSBugsnagObjC/CrashKiOSBugsnagObjC.m new file mode 100644 index 0000000..335c284 --- /dev/null +++ b/Sources/CrashKiOSBugsnagObjC/CrashKiOSBugsnagObjC.m @@ -0,0 +1 @@ +// We need this empty file, else SPM won't build this header-only target. diff --git a/Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h b/Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h new file mode 100644 index 0000000..7d4597f --- /dev/null +++ b/Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h @@ -0,0 +1,41 @@ +#ifndef CrashKiOSBugsnagSink_h +#define CrashKiOSBugsnagSink_h + +#import + +/// Receives crash-reporting events from Kotlin and forwards them to Bugsnag. +/// +/// Implemented in Swift, inside the app's build graph — where Bugsnag is already +/// linked — and registered with the Kotlin side via `registerBugsnagSink`. +/// +/// Contract for implementations (the shipped `BugsnagSink` does all of this): +/// - MUST NOT throw: Kotlin cannot catch ObjC exceptions thrown across this +/// boundary (KT-53004), and the fatal path runs inside the unhandled-exception hook. +/// - MUST prepare the Bugsnag configuration with `configureBugsnagForKotlin(_:)` +/// (CrashKiOSBugsnag package) before `Bugsnag.start`, otherwise every fatal Kotlin +/// exception is reported twice (the Kotlin notify plus Bugsnag's own report of the +/// termination abort). +@protocol CrashKiOSBugsnagSink + +/// Bugsnag `leaveBreadcrumbWithMessage:`. +- (void)leaveBreadcrumb:(NSString * _Nonnull)message; + +/// Notify Bugsnag of a Kotlin exception. `exceptions` is `[main, cause, causeOfCause, ...]`; +/// each element's `callStackReturnAddresses` carries its Kotlin stack trace. +/// When `handled` is NO the event must be persisted synchronously before returning +/// (`Bugsnag.notify` does) — the process may terminate right after. +- (void)notifyWithExceptions:(NSArray * _Nonnull)exceptions handled:(BOOL)handled; + +/// Bugsnag `addMetadata:key:section:`. +- (void)addMetadata:(id _Nonnull)value key:(NSString * _Nonnull)key section:(NSString * _Nonnull)section; + +/// Called by the Kotlin unhandled-exception hook — and ONLY that hook — right after +/// the fatal notify, just before the process terminates. Implementations flag the +/// session (Bugsnag feature flag `crashkios.kotlin_crashed`) so the OnSendError filter +/// installed by `configureBugsnagForKotlin(_:)` drops Bugsnag's own report of the +/// termination abort. Direct `sendFatalException()` calls do NOT trigger this. +- (void)markFatalCrashRecorded; + +@end + +#endif /* CrashKiOSBugsnagSink_h */ diff --git a/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift b/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift new file mode 100644 index 0000000..3890313 --- /dev/null +++ b/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift @@ -0,0 +1,46 @@ +import Foundation +import CrashKiOSCrashlyticsObjC +import FirebaseCrashlytics + +/// Reference `CrashKiOSCrashlyticsSink` implementation. +/// +/// Register from your `AppDelegate`, right after Firebase is configured and before +/// any Kotlin code runs: +/// ```swift +/// FirebaseApp.configure() +/// CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) +/// ``` +/// (The Kotlin function name depends on your framework's export configuration; add +/// `export("co.touchlab.crashkios:crashlytics")` to the framework for clean names.) +public final class CrashlyticsSink: NSObject, CrashKiOSCrashlyticsSink { + + private let crashlytics: Crashlytics + + public init(_ crashlytics: Crashlytics = Crashlytics.crashlytics()) { + self.crashlytics = crashlytics + } + + public func logMessage(_ message: String) { + crashlytics.log(message) + } + + public func recordHandledException(withName name: String, reason: String, stackAddresses: [NSNumber]) { + let model = ExceptionModel(name: name, reason: reason) + model.stackTrace = stackAddresses.map { StackFrame(address: $0.uintValue) } + crashlytics.record(exceptionModel: model) + } + + public func recordFatalException(_ exception: NSException) { + // Persists synchronously; Crashlytics stores a single fatal per session, so the + // termination abort that follows is not double-reported. + FIRCLSExceptionRecordNSException(exception) + } + + public func setCustomValue(_ value: Any?, forKey key: String) { + crashlytics.setCustomValue(value as Any, forKey: key) + } + + public func setUserId(_ identifier: String) { + crashlytics.setUserID(identifier) + } +} diff --git a/Sources/CrashKiOSCrashlyticsObjC/CrashKiOSCrashlyticsObjC.m b/Sources/CrashKiOSCrashlyticsObjC/CrashKiOSCrashlyticsObjC.m new file mode 100644 index 0000000..335c284 --- /dev/null +++ b/Sources/CrashKiOSCrashlyticsObjC/CrashKiOSCrashlyticsObjC.m @@ -0,0 +1 @@ +// We need this empty file, else SPM won't build this header-only target. diff --git a/Sources/CrashKiOSCrashlyticsObjC/include/CrashKiOSCrashlyticsSink.h b/Sources/CrashKiOSCrashlyticsObjC/include/CrashKiOSCrashlyticsSink.h new file mode 100644 index 0000000..8552529 --- /dev/null +++ b/Sources/CrashKiOSCrashlyticsObjC/include/CrashKiOSCrashlyticsSink.h @@ -0,0 +1,37 @@ +#ifndef CrashKiOSCrashlyticsSink_h +#define CrashKiOSCrashlyticsSink_h + +#import + +/// Receives crash-reporting events from Kotlin and forwards them to Firebase Crashlytics. +/// +/// Implemented in Swift, inside the app's build graph — where FirebaseCrashlytics is +/// already linked — and registered with the Kotlin side via `registerCrashlyticsSink`. +/// +/// Implementations MUST NOT throw: Kotlin cannot catch ObjC exceptions thrown across +/// this boundary (KT-53004), and the fatal path runs inside the unhandled-exception hook. +@protocol CrashKiOSCrashlyticsSink + +/// Crashlytics `log:`. +- (void)logMessage:(NSString * _Nonnull)message; + +/// A non-fatal exception. `addresses` are Kotlin stack-trace return addresses, +/// mappable to `FIRStackFrame stackFrameWithAddress:`. +- (void)recordHandledExceptionWithName:(NSString * _Nonnull)name + reason:(NSString * _Nonnull)reason + stackAddresses:(NSArray * _Nonnull)addresses; + +/// A fatal exception. The process terminates right after this returns — +/// the record must be persisted synchronously (FIRCLSExceptionRecordNSException does). +/// `exception.callStackReturnAddresses` carries the Kotlin stack trace. +- (void)recordFatalException:(NSException * _Nonnull)exception; + +/// Crashlytics `setCustomValue:forKey:`. +- (void)setCustomValue:(id _Nullable)value forKey:(NSString * _Nonnull)key; + +/// Crashlytics `setUserID:`. +- (void)setUserId:(NSString * _Nonnull)identifier; + +@end + +#endif /* CrashKiOSCrashlyticsSink_h */ diff --git a/Sources/CrashKiOSCrashlyticsObjC/include/FIRCLSException.h b/Sources/CrashKiOSCrashlyticsObjC/include/FIRCLSException.h new file mode 100644 index 0000000..6cc33cd --- /dev/null +++ b/Sources/CrashKiOSCrashlyticsObjC/include/FIRCLSException.h @@ -0,0 +1,30 @@ +#ifndef CrashKiOSFIRCLSException_h +#define CrashKiOSFIRCLSException_h + +// Declaration copied from firebase-ios-sdk's private header: +// https://github.com/firebase/firebase-ios-sdk/blob/main/Crashlytics/Crashlytics/Handlers/FIRCLSException.h +// +// Copyright 2019-2020 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import + +// Records `exception` as the session's fatal exception and persists it synchronously. +// Private Crashlytics SPI, resolved HARD at app link time via the FirebaseCrashlytics +// product this target depends on — nothing dynamic is left for the App Store's +// deployment-processing strip pass to break (the CrashKiOS <= 0.9.0 failure mode). +// Fallback if Firebase ever removes it: -[FIRCrashlytics recordOnDemandExceptionModel:]. +extern void FIRCLSExceptionRecordNSException(NSException *exception); + +#endif /* CrashKiOSFIRCLSException_h */ From 2f43197fa332b968adbb6c4f1abb1a7e7d137a01 Mon Sep 17 00:00:00 2001 From: Wellington Pereira Date: Wed, 8 Jul 2026 20:31:53 -0300 Subject: [PATCH 3/7] Rewrite bugsnag module --- bugsnag/build.gradle.kts | 12 +- .../co/touchlab/crashkios/bugsnag/Bugsnag.kt | 53 ++++++++ .../crashkios/bugsnag/BugsnagCallsActual.kt | 51 +++----- .../crashkios/bugsnag/BugsnagConfig.kt | 67 ---------- .../crashkios/bugsnag/BugsnagSinkTest.kt | 108 ++++++++++++++++ .../crashkios/bugsnag/BugsnagKotlin.kt | 7 +- bugsnag/src/include/Bugsnag-old.h | 116 ------------------ bugsnag/src/include/Bugsnag.h | 98 --------------- .../BugsnagConfiguration+NSExceptionKt.h | 11 -- bugsnag/src/include/BugsnagConfiguration.h | 30 ----- bugsnag/src/include/BugsnagError.h | 33 ----- bugsnag/src/include/BugsnagEvent.h | 34 ----- bugsnag/src/include/BugsnagFeatureFlag.h | 23 ---- bugsnag/src/include/BugsnagFeatureFlagStore.h | 24 ---- bugsnag/src/include/BugsnagStackframe.h | 23 ---- .../BugsnagHandledState+NSExceptionKt.h | 14 --- .../src/include/Private/BugsnagHandledState.h | 23 ---- .../src/nativeInterop/cinterop/bugsnag.def | 11 +- 18 files changed, 195 insertions(+), 543 deletions(-) create mode 100644 bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt delete mode 100644 bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagConfig.kt create mode 100644 bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt delete mode 100644 bugsnag/src/include/Bugsnag-old.h delete mode 100644 bugsnag/src/include/Bugsnag.h delete mode 100644 bugsnag/src/include/BugsnagConfiguration+NSExceptionKt.h delete mode 100644 bugsnag/src/include/BugsnagConfiguration.h delete mode 100644 bugsnag/src/include/BugsnagError.h delete mode 100644 bugsnag/src/include/BugsnagEvent.h delete mode 100644 bugsnag/src/include/BugsnagFeatureFlag.h delete mode 100644 bugsnag/src/include/BugsnagFeatureFlagStore.h delete mode 100644 bugsnag/src/include/BugsnagStackframe.h delete mode 100644 bugsnag/src/include/Private/BugsnagHandledState+NSExceptionKt.h delete mode 100644 bugsnag/src/include/Private/BugsnagHandledState.h diff --git a/bugsnag/build.gradle.kts b/bugsnag/build.gradle.kts index e74a79b..9baec00 100644 --- a/bugsnag/build.gradle.kts +++ b/bugsnag/build.gradle.kts @@ -63,12 +63,6 @@ kotlin { implementation(kotlin("test")) } } - appleMain { - dependencies { - implementation(libs.nsexceptionKt.core) - } - } - androidMain { dependencies { compileOnly(libs.bugsnag.android) @@ -79,10 +73,8 @@ kotlin { val mainCompilation = compilations.getByName("main") mainCompilation.cinterops.create("bugsnag") { - includeDirs("$projectDir/src/include") - includeDirs("$projectDir/src/include/private") - compilerOpts("-DNS_FORMAT_ARGUMENT(A)=", "-D_Nullable_result=_Nullable") -// extraOpts("-mode", "sourcecode") + // Protocol-only header owned by the Swift package — no SDK symbols. + includeDirs("$rootDir/Sources/CrashKiOSBugsnagObjC/include") } } } diff --git a/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt new file mode 100644 index 0000000..632d4a2 --- /dev/null +++ b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt @@ -0,0 +1,53 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package co.touchlab.crashkios.bugsnag + +import co.touchlab.crashkios.bugsnag.objc.CrashKiOSBugsnagSinkProtocol +import kotlinx.cinterop.ExperimentalForeignApi + +/** + * Registers the Swift-implemented [sink] that forwards CrashKiOS events to Bugsnag, + * and installs the unhandled-exception hook so unhandled Kotlin exceptions are + * reported as fatal crashes. + * + * Call once, from Swift, at startup. Start Bugsnag through the shipped sink so the + * configuration suppresses the duplicate termination crash: + * ```swift + * let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) + * BugsnagKt.registerBugsnagSink(sink: sink) + * ``` + * `BugsnagSink` ships in the `CrashKiOSBugsnag` Swift package (this repo's + * `Package.swift`); custom sink implementations must call + * `configureBugsnagForKotlin(config)` (from the same package) before `Bugsnag.start` + * to keep the duplicate-crash suppression. Add + * `export("co.touchlab.crashkios:bugsnag")` to your framework configuration for + * clean unmangled names. + * + * Replaces `startBugsnag()` / `configureBugsnag()` / `setBugsnagUnhandledExceptionHook()`, + * which were removed along with the Bugsnag cinterop — Bugsnag start and configuration + * now live in Swift. + * + * Calling `enableBugsnag()` (or constructing `BugsnagCallsActual`) on an Apple target + * WITHOUT having registered a sink fails fast with a descriptive error rather than + * silently dropping crash reports. + */ +public fun registerBugsnagSink(sink: CrashKiOSBugsnagSinkProtocol) { + bugsnagRegistry.register(sink, fatalHook(sink)) + BugsnagKotlin.implementation = BugsnagCallsActual() +} + +/** + * The terminating-hook body: reports the exception, then marks the session so the + * sink's OnSendError filter drops Bugsnag's own report of the termination abort that + * follows. Only the terminating hook path marks: a direct sendFatalException() call + * must NOT poison the session (the caller may keep running, and later genuine crashes + * would be discarded). + * + * A plain function rather than an inline lambda so tests can exercise the + * notify-then-mark sequencing directly, without going through the real + * process-terminating unhandled-exception hook. + */ +internal fun fatalHook(sink: CrashKiOSBugsnagSinkProtocol): (Throwable) -> Unit = { throwable -> + BugsnagKotlin.sendFatalException(throwable) + sink.markFatalCrashRecorded() +} diff --git a/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt index e331a17..9a86237 100644 --- a/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt +++ b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt @@ -1,47 +1,36 @@ +@file:OptIn(ExperimentalForeignApi::class) + package co.touchlab.crashkios.bugsnag -import com.rickclephas.kmp.nsexceptionkt.core.asNSException -import com.rickclephas.kmp.nsexceptionkt.core.causes +import co.touchlab.crashkios.bugsnag.objc.CrashKiOSBugsnagSinkProtocol +import co.touchlab.crashkios.core.CrashSinkRegistry +import co.touchlab.crashkios.core.asNSException +import co.touchlab.crashkios.core.causes import kotlinx.cinterop.ExperimentalForeignApi -@OptIn(ExperimentalForeignApi::class) +internal val bugsnagRegistry = CrashSinkRegistry("Bugsnag") + actual class BugsnagCallsActual : BugsnagCalls { + // Fail-fast: an implementation constructed without a registered Swift sink would + // silently lose every event — refuse loudly instead. + private val sink: CrashKiOSBugsnagSinkProtocol = bugsnagRegistry.requireSink() + actual override fun logMessage(message: String) { - Bugsnag.leaveBreadcrumbWithMessage(message) + sink.leaveBreadcrumb(message) } - actual override fun sendHandledException(throwable: Throwable) { - sendException(throwable, true) - } + actual override fun sendHandledException(throwable: Throwable) = sendException(throwable, true) - actual override fun sendFatalException(throwable: Throwable) { - sendException(throwable, false) - } + actual override fun sendFatalException(throwable: Throwable) = sendException(throwable, false) actual override fun setCustomValue(section: String, key: String, value: Any) { - Bugsnag.addMetadata(value, key, section) + sink.addMetadata(value, key = key, section = section) } private fun sendException(throwable: Throwable, handled: Boolean) { - val exception = throwable.asNSException() - val causes = throwable.causes.map { it.asNSException() } - // Notify will persist unhandled events, so we can safely terminate afterwards. - // https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/Client/BugsnagClient.m#L744 - Bugsnag.notify(exception) { event -> - if (event == null) return@notify true - - if (handled) { - event.severity = BSGSeverity.BSGSeverityWarning - } else { - event.unhandled = true - event.severity = BSGSeverity.BSGSeverityError - } - - if (causes.isNotEmpty()) { - event.errors += causes.map { it.asBugsnagError() } - } - - true - } + val exceptions = listOf(throwable.asNSException()) + throwable.causes.map { it.asNSException() } + // The sink persists unhandled events synchronously (Bugsnag.notify), + // so the caller can safely terminate afterwards. + sink.notifyWithExceptions(exceptions, handled = handled) } } diff --git a/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagConfig.kt b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagConfig.kt deleted file mode 100644 index 1278028..0000000 --- a/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagConfig.kt +++ /dev/null @@ -1,67 +0,0 @@ -@file:OptIn(ExperimentalForeignApi::class) - -package co.touchlab.crashkios.bugsnag - -import com.rickclephas.kmp.nsexceptionkt.core.asNSException -import com.rickclephas.kmp.nsexceptionkt.core.causes -import com.rickclephas.kmp.nsexceptionkt.core.wrapUnhandledExceptionHook -import kotlinx.cinterop.ExperimentalForeignApi -import platform.Foundation.NSException - -public fun startBugsnag(config: BugsnagConfiguration) { - configureBugsnag(config) - Bugsnag.startWithConfiguration(config) - setBugsnagUnhandledExceptionHook() - enableBugsnag() -} - -/** - * Configures Bugsnag to ignore the Kotlin termination crash. - */ -public fun configureBugsnag(config: BugsnagConfiguration) { - NSExceptionKt_OverrideBugsnagHandledStateOriginalUnhandledValue() - NSExceptionKt_BugsnagConfigAddOnSendErrorBlock(config) { event -> - if (event == null) return@NSExceptionKt_BugsnagConfigAddOnSendErrorBlock true - !event.unhandled || event.featureFlags.none { (it as BugsnagFeatureFlag).name == kotlinCrashedFeatureFlag } - } - config.clearFeatureFlagWithName(kotlinCrashedFeatureFlag) -} - -/** - * Sets the unhandled exception hook such that all unhandled exceptions are logged to Bugsnag as fatal exceptions. - * If an unhandled exception hook was already set, that hook will be invoked after the exception is logged. - * Note: once the exception is logged the program will be terminated. - * @see wrapUnhandledExceptionHook - */ -public fun setBugsnagUnhandledExceptionHook(): Unit = wrapUnhandledExceptionHook { throwable -> - val exception = throwable.asNSException() - val causes = throwable.causes.map { it.asNSException() } - // Notify will persist unhandled events, so we can safely terminate afterwards. - // https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/Client/BugsnagClient.m#L744 - Bugsnag.notify(exception) { event -> - if (event == null) return@notify true - event.unhandled = true - event.severity = BSGSeverity.BSGSeverityError - if (causes.isNotEmpty()) { - event.errors += causes.map { it.asBugsnagError() } - } - true - } - Bugsnag.addFeatureFlagWithName(kotlinCrashedFeatureFlag) -} - -/** - * Feature flag used to mark the Kotlin termination crash. - */ -@Suppress("ktlint:standard:property-naming") -private const val kotlinCrashedFeatureFlag = "crashkios.kotlin_crashed" - -/** - * Converts `this` [NSException] to a [BugsnagError]. - */ -internal fun NSException.asBugsnagError(): BugsnagError = BugsnagError().apply { - errorClass = name - errorMessage = reason - stacktrace = BugsnagStackframe.stackframesWithCallStackReturnAddresses(callStackReturnAddresses) - type = BSGErrorType.BSGErrorTypeCocoa -} diff --git a/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt b/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt new file mode 100644 index 0000000..8f93565 --- /dev/null +++ b/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt @@ -0,0 +1,108 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package co.touchlab.crashkios.bugsnag + +import co.touchlab.crashkios.bugsnag.objc.CrashKiOSBugsnagSinkProtocol +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSException +import platform.darwin.NSObject + +// Kotlin stand-in for the Swift sink: exercises the same ObjC-protocol boundary and +// proves the module links with zero linker flags (the old -U / RND-91 failure mode). +private class FakeBugsnagSink : + NSObject(), + CrashKiOSBugsnagSinkProtocol { + val breadcrumbs = mutableListOf() + val notifications = mutableListOf, Boolean>>() + val metadata = mutableListOf>() + var fatalMarks = 0 + + // Call order across notify/mark — proves the hook sequences them correctly, + // not just that both eventually happened. + val events = mutableListOf() + + override fun leaveBreadcrumb(message: String) { + breadcrumbs += message + } + + override fun markFatalCrashRecorded() { + fatalMarks++ + events += "mark" + } + + override fun notifyWithExceptions(exceptions: List<*>, handled: Boolean) { + notifications += exceptions to handled + events += "notify" + } + + override fun addMetadata(value: Any, key: String, section: String) { + metadata += Triple(value, key, section) + } +} + +class BugsnagSinkTest { + // registerBugsnagSink() mutates the module-level `bugsnagRegistry` singleton. Safe + // to call from every test that needs BugsnagKotlin's facade routed to its own sink + // (sinkRef always overwrites) — NOT safe to rely on for the real unhandled-exception + // hook actually firing with a given test's sink: hook-install is a once-ever guard, + // permanently bound to whichever sink registered first. That's why fatalHookNotifiesThenMarksSession + // below calls fatalHook() directly instead of triggering the real hook. + @Test + fun facadeForwardsToRegisteredSink() { + val sink = FakeBugsnagSink() + registerBugsnagSink(sink) + + BugsnagKotlin.logMessage("crumb") + BugsnagKotlin.setCustomValue("section1", "key1", "value1") + BugsnagKotlin.sendHandledException(RuntimeException("handled boom")) + BugsnagKotlin.sendFatalException(RuntimeException("fatal boom", IllegalStateException("root cause"))) + + assertEquals(listOf("crumb"), sink.breadcrumbs) + + val (value, key, section) = sink.metadata.single() + assertEquals("value1", value?.toString()) + assertEquals("key1", key) + assertEquals("section1", section) + + assertEquals(2, sink.notifications.size) + val (handledExceptions, handledFlag) = sink.notifications[0] + assertTrue(handledFlag) + assertEquals(1, handledExceptions.size) + assertEquals("kotlin.RuntimeException", (handledExceptions.single() as NSException).name) + + val (fatalExceptions, fatalFlag) = sink.notifications[1] + assertEquals(false, fatalFlag) + assertEquals(2, fatalExceptions.size, "fatal should carry the cause chain") + assertEquals("kotlin.IllegalStateException", (fatalExceptions[1] as NSException).name) + + // Only the terminating unhandled-exception hook may mark the session — + // a direct sendFatalException() must not (it would poison the OnSendError filter). + assertEquals(0, sink.fatalMarks) + } + + @Test + fun fatalHookNotifiesThenMarksSession() { + // fatalHook() is the terminating hook's actual body (see Bugsnag.kt), exercised + // directly rather than through wrapUnhandledExceptionHook — invoking the real + // hook would terminate the process. + val sink = FakeBugsnagSink() + // Rebinds BugsnagKotlin.implementation to this sink (register() always + // overwrites sinkRef) so fatalHook's sendFatalException() call routes here + // rather than to whatever sink an earlier test left registered. Does NOT wire + // this sink into the real unhandled-exception hook — that binds once, to + // whichever sink registers first — which is exactly why fatalHook is called + // directly below instead of relying on the real hook firing. + registerBugsnagSink(sink) + + fatalHook(sink)(RuntimeException("terminating boom")) + + assertEquals(1, sink.fatalMarks) + assertEquals(1, sink.notifications.size) + // Order matters: markFatalCrashRecorded() must run AFTER notify, never before — + // marking first would let the OnSendError filter drop a report that was never sent. + assertEquals(listOf("notify", "mark"), sink.events) + } +} diff --git a/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt b/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt index 97ef3b2..19598ff 100644 --- a/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt +++ b/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt @@ -23,8 +23,11 @@ object BugsnagKotlin { } /** - * Call in startup code in an actual app. Tests should generally skip this. In Kotlin/Native, not calling this - * for tests avoids linker issues. + * Call in startup code on Android. Tests should generally skip this. + * + * On Apple targets this alone is NOT enough: a Swift sink must be registered via + * `registerBugsnagSink()` (which also sets the implementation, making this call + * redundant there) — otherwise events are dropped with an NSLog warning. */ fun enableBugsnag() { BugsnagKotlin.implementation = BugsnagCallsActual() diff --git a/bugsnag/src/include/Bugsnag-old.h b/bugsnag/src/include/Bugsnag-old.h deleted file mode 100644 index b6f8094..0000000 --- a/bugsnag/src/include/Bugsnag-old.h +++ /dev/null @@ -1,116 +0,0 @@ -// -// Bugsnag.h -// -// Created by Conrad Irwin on 2014-10-01. -// -// Copyright (c) 2014 Bugsnag, Inc. All rights reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall remain in place -// in this source code. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -// THE SOFTWARE. -// -#import - -/** - * Types of breadcrumbs - */ -typedef NS_ENUM(NSUInteger, BSGBreadcrumbType) { - /** - * Any breadcrumb sent via Bugsnag.leaveBreadcrumb() - */ - BSGBreadcrumbTypeManual, - /** - * A call to Bugsnag.notify() (internal use only) - */ - BSGBreadcrumbTypeError, - /** - * A log message - */ - BSGBreadcrumbTypeLog, - /** - * A navigation action, such as pushing a view controller or dismissing an alert - */ - BSGBreadcrumbTypeNavigation, - /** - * A background process, such performing a database query - */ - BSGBreadcrumbTypeProcess, - /** - * A network request - */ - BSGBreadcrumbTypeRequest, - /** - * Change in application or view state - */ - BSGBreadcrumbTypeState, - /** - * A user event, such as authentication or control events - */ - BSGBreadcrumbTypeUser, -}; - -/** - * Static access to a Bugsnag Client, the easiest way to use Bugsnag in your app. - */ -@interface Bugsnag : NSObject// - -/** - * All Bugsnag access is class-level. Prevent the creation of instances. - */ -- (instancetype _Nonnull )init NS_UNAVAILABLE NS_SWIFT_UNAVAILABLE("Use class methods to initialise Bugsnag."); - -// ============================================================================= -// MARK: - Notify -// ============================================================================= - -/** - * Send a custom or caught exception to Bugsnag. - * - * The exception will be sent to Bugsnag in the background allowing your - * app to continue running. - * - * @param exception The exception. - */ -+ (void)notify:(NSException *_Nonnull)exception; - -// ============================================================================= -// MARK: - Breadcrumbs -// ============================================================================= - -/** - * Leave a "breadcrumb" log message, representing an action that occurred - * in your app, to aid with debugging. - * - * @param message the log message to leave - */ -+ (void)leaveBreadcrumbWithMessage:(NSString *_Nonnull)message; - -/** - * Leave a "breadcrumb" log message, representing an action that occurred - * in your app, to aid with debugging, along with additional metadata and - * a type. - * - * @param message The log message to leave. - * @param metadata Diagnostic data relating to the breadcrumb. - * Values should be serializable to JSON with NSJSONSerialization. - * @param type A BSGBreadcrumbTypeValue denoting the type of breadcrumb. - */ -+ (void)leaveBreadcrumbWithMessage:(NSString *_Nonnull)message - metadata:(NSDictionary *_Nullable)metadata - andType:(BSGBreadcrumbType)type - NS_SWIFT_NAME(leaveBreadcrumb(_:metadata:type:)); -@end diff --git a/bugsnag/src/include/Bugsnag.h b/bugsnag/src/include/Bugsnag.h deleted file mode 100644 index 3eafd00..0000000 --- a/bugsnag/src/include/Bugsnag.h +++ /dev/null @@ -1,98 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/include/Bugsnag/Bugsnag.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -// ----------------------------------------------------------------------------- -// MARK: - -// ----------------------------------------------------------------------------- - -/** - * A class-level protocol supporting the MetadataStore interface - */ -@protocol BugsnagClassLevelMetadataStore - -@required - -/** - * Merge supplied and existing metadata. - * - * - Non-null values will replace existing values for identical keys. - * - * - Null values will remove the existing key/value pair if the key exists. - * Where null-valued keys do not exist they will not be set. (Since ObjC - * dicts can't store 'nil' directly we assume [NSNUll null]) - * - * - Tabs are only created if at least one value is valid. - * - * - Invalid values (i.e. unserializable to JSON) are logged and ignored. - * - * @param metadata A dictionary of string -> id key/value pairs. - * Values should be serializable to JSON. - * - * @param sectionName The name of the metadata section - * - */ -+ (void)addMetadata:(NSDictionary *_Nonnull)metadata - toSection:(NSString *_Nonnull)sectionName - NS_SWIFT_NAME(addMetadata(_:section:)); - -/** - * Add a piece of metadata to a particular key in a particular section. - * - * - Non-null values will replace existing values for identical keys. - * - * - Null values will remove the existing key/value pair if the key exists. - * Where null-valued keys do not exist they will not be set. (Since ObjC - * dicts can't store 'nil' directly we assume [NSNUll null]) - * - * - Tabs are only created if at least one value is valid. - * - * - Invalid values (i.e. unserializable to JSON) are logged and ignored. - * - * @param metadata A dictionary of string -> id key/value pairs. - * Values should be serializable to JSON. - * - * @param key The metadata key to store the value under - * - * @param sectionName The name of the metadata section - * - */ -+ (void)addMetadata:(id _Nullable)metadata - withKey:(NSString *_Nonnull)key - toSection:(NSString *_Nonnull)sectionName - NS_SWIFT_NAME(addMetadata(_:key:section:)); - -@end - -@interface BugsnagClient : NSObject -@end - -@interface Bugsnag : NSObject - -+ (void)notify:(NSException *_Nonnull)exception block:(BugsnagOnErrorBlock _Nullable)block; -+ (void)addFeatureFlagWithName:(nonnull NSString *)name; -+ (void)leaveBreadcrumbWithMessage:(NSString *_Nonnull)message; -+ (BugsnagClient *_Nonnull)startWithConfiguration:(BugsnagConfiguration *_Nonnull)configuration; - -@end - - - -NS_ASSUME_NONNULL_END diff --git a/bugsnag/src/include/BugsnagConfiguration+NSExceptionKt.h b/bugsnag/src/include/BugsnagConfiguration+NSExceptionKt.h deleted file mode 100644 index bece03c..0000000 --- a/bugsnag/src/include/BugsnagConfiguration+NSExceptionKt.h +++ /dev/null @@ -1,11 +0,0 @@ -#import -#import -#import - -// We need the following wrapper function because of -// https://github.com/JetBrains/kotlin/blob/7bc0132cca92464344ded194f2273c56699f99ca/kotlin-native/runtime/src/main/cpp/ObjCExport.mm#L515 -void NSExceptionKt_BugsnagConfigAddOnSendErrorBlock(BugsnagConfiguration* config, BugsnagOnSendErrorBlock block) { - [config addOnSendErrorBlock:^BOOL(BugsnagEvent * _Nonnull event) { - return block(event); - }]; -} diff --git a/bugsnag/src/include/BugsnagConfiguration.h b/bugsnag/src/include/BugsnagConfiguration.h deleted file mode 100644 index d7efc8c..0000000 --- a/bugsnag/src/include/BugsnagConfiguration.h +++ /dev/null @@ -1,30 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/include/Bugsnag/BugsnagConfiguration.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import -#import - -typedef BOOL (^BugsnagOnErrorBlock)(BugsnagEvent *_Nonnull event); - -typedef BOOL (^BugsnagOnSendErrorBlock)(BugsnagEvent *_Nonnull event); - -typedef id BugsnagOnSendErrorRef; - -@interface BugsnagConfiguration : NSObject - -- (BugsnagOnSendErrorRef)addOnSendErrorBlock:(BugsnagOnSendErrorBlock)block; - -@end diff --git a/bugsnag/src/include/BugsnagError.h b/bugsnag/src/include/BugsnagError.h deleted file mode 100644 index 46779b8..0000000 --- a/bugsnag/src/include/BugsnagError.h +++ /dev/null @@ -1,33 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/include/Bugsnag/BugsnagError.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import -#import - -typedef NS_OPTIONS(NSUInteger, BSGErrorType) { - BSGErrorTypeCocoa, - BSGErrorTypeC, - BSGErrorTypeReactNativeJs -}; - -@interface BugsnagError : NSObject - -@property (copy, nullable, nonatomic) NSString *errorClass; -@property (copy, nullable, nonatomic) NSString *errorMessage; -@property (copy, nonnull, nonatomic) NSArray *stacktrace; -@property (nonatomic) BSGErrorType type; - -@end diff --git a/bugsnag/src/include/BugsnagEvent.h b/bugsnag/src/include/BugsnagEvent.h deleted file mode 100644 index babc54b..0000000 --- a/bugsnag/src/include/BugsnagEvent.h +++ /dev/null @@ -1,34 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/include/Bugsnag/BugsnagEvent.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import -#import -#import - -typedef NS_ENUM(NSUInteger, BSGSeverity) { - BSGSeverityError, - BSGSeverityWarning, - BSGSeverityInfo, -}; - -@interface BugsnagEvent : NSObject - -@property (readwrite, nonatomic) BSGSeverity severity; -@property (readwrite, copy, nonnull, nonatomic) NSArray *errors; -@property (readonly, strong, nonnull, nonatomic) NSArray *featureFlags; -@property (readwrite, nonatomic) BOOL unhandled; - -@end diff --git a/bugsnag/src/include/BugsnagFeatureFlag.h b/bugsnag/src/include/BugsnagFeatureFlag.h deleted file mode 100644 index 92d34e0..0000000 --- a/bugsnag/src/include/BugsnagFeatureFlag.h +++ /dev/null @@ -1,23 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/include/Bugsnag/BugsnagFeatureFlag.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import - -@interface BugsnagFeatureFlag : NSObject - -@property (readonly, nonatomic, nonnull) NSString *name; - -@end diff --git a/bugsnag/src/include/BugsnagFeatureFlagStore.h b/bugsnag/src/include/BugsnagFeatureFlagStore.h deleted file mode 100644 index 11f2940..0000000 --- a/bugsnag/src/include/BugsnagFeatureFlagStore.h +++ /dev/null @@ -1,24 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/include/Bugsnag/BugsnagFeatureFlagStore.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import -#import - -@interface BugsnagConfiguration () - -- (void)clearFeatureFlagWithName:(NSString *_Nullable)name; - -@end diff --git a/bugsnag/src/include/BugsnagStackframe.h b/bugsnag/src/include/BugsnagStackframe.h deleted file mode 100644 index 4edb524..0000000 --- a/bugsnag/src/include/BugsnagStackframe.h +++ /dev/null @@ -1,23 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/bd0465cd0e753ca42eef59fef4d5ceda80da1222/Bugsnag/include/Bugsnag/BugsnagStackframe.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import - -@interface BugsnagStackframe : NSObject - -+ (NSArray *_Nonnull)stackframesWithCallStackReturnAddresses:(NSArray *_Nonnull)callStackReturnAddresses; - -@end diff --git a/bugsnag/src/include/Private/BugsnagHandledState+NSExceptionKt.h b/bugsnag/src/include/Private/BugsnagHandledState+NSExceptionKt.h deleted file mode 100644 index e6f0238..0000000 --- a/bugsnag/src/include/Private/BugsnagHandledState+NSExceptionKt.h +++ /dev/null @@ -1,14 +0,0 @@ -#import -#import - -BOOL NSExceptionKt_BugsnagHandledStateOriginalUnhandledValue(BugsnagHandledState* self, SEL _cmd) { - return self.unhandled; -} - -// In Bugsnag 6.26.2 and above we need to override the originalUnhandledValue property. -// By default it will prevent our exceptions from being stored to disk. -// https://github.com/bugsnag/bugsnag-cocoa/pull/1549 -void NSExceptionKt_OverrideBugsnagHandledStateOriginalUnhandledValue() { - Method method = class_getInstanceMethod([BugsnagHandledState class], @selector(originalUnhandledValue)); - method_setImplementation(method, (IMP)NSExceptionKt_BugsnagHandledStateOriginalUnhandledValue); -} \ No newline at end of file diff --git a/bugsnag/src/include/Private/BugsnagHandledState.h b/bugsnag/src/include/Private/BugsnagHandledState.h deleted file mode 100644 index bf4a0f5..0000000 --- a/bugsnag/src/include/Private/BugsnagHandledState.h +++ /dev/null @@ -1,23 +0,0 @@ -// The following are snippets from the Bugsnag Cocoa SDK used to generate Kotlin stubs. -// -// https://github.com/bugsnag/bugsnag-cocoa/blob/6bcd46f5f8dc06ac26537875d501f02b27d219a9/Bugsnag/Payload/BugsnagHandledState.h -// -// Copyright (c) 2012 Bugsnag, https://bugsnag.com/ -// -// Permission is hereby granted, free of charge, to any person obtaining -// a copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the Software -// is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -#import - -@interface BugsnagHandledState : NSObject - -@property(nonatomic) BOOL unhandled; - -@end \ No newline at end of file diff --git a/bugsnag/src/nativeInterop/cinterop/bugsnag.def b/bugsnag/src/nativeInterop/cinterop/bugsnag.def index ad438bb..f75dde5 100644 --- a/bugsnag/src/nativeInterop/cinterop/bugsnag.def +++ b/bugsnag/src/nativeInterop/cinterop/bugsnag.def @@ -1,6 +1,9 @@ -package = co.touchlab.crashkios.bugsnag +package = co.touchlab.crashkios.bugsnag.objc language = Objective-C -headers = Bugsnag.h BugsnagConfiguration.h BugsnagConfiguration+NSExceptionKt.h BugsnagError.h BugsnagEvent.h \ -BugsnagFeatureFlag.h BugsnagFeatureFlagStore.h BugsnagStackframe.h Private/BugsnagHandledState.h \ -Private/BugsnagHandledState+NSExceptionKt.h +headers = CrashKiOSBugsnagSink.h +# Protocol-only cinterop: the header (owned by the Swift package, see +# Sources/CrashKiOSBugsnagObjC/include/) declares no external classes or +# functions, so this emits NO undefined symbols — no -U flags, no cacheKind=none, +# no App Store strip hazard. The Swift app implements the protocol and registers +# it via registerBugsnagSink(). From 72ec0a57932fe8ec5d636f1381e7a99d7e4ee0ca Mon Sep 17 00:00:00 2001 From: Wellington Pereira Date: Wed, 8 Jul 2026 20:34:26 -0300 Subject: [PATCH 4/7] Rewrite crashlytics module --- crashlytics/build.gradle.kts | 11 +- .../crashkios/crashlytics/Crashlytics.kt | 44 ++++- .../crashlytics/CrashlyticsCallsActual.kt | 48 +++--- .../crashlytics/CrashlyticsSinkTest.kt | 72 ++++++++ .../crashlytics/CrashlyticsKotlin.kt | 7 +- .../nativeInterop/cinterop/crashlytics.def | 158 +----------------- 6 files changed, 147 insertions(+), 193 deletions(-) create mode 100644 crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt diff --git a/crashlytics/build.gradle.kts b/crashlytics/build.gradle.kts index db207a0..ccc09cd 100644 --- a/crashlytics/build.gradle.kts +++ b/crashlytics/build.gradle.kts @@ -63,12 +63,6 @@ kotlin { implementation(kotlin("test")) } } - appleMain { - dependencies { - implementation(libs.nsexceptionKt.core) - } - } - androidMain { dependencies { compileOnly(libs.firebase.crashlytics) @@ -78,9 +72,8 @@ kotlin { targets.withType().all { val mainCompilation = compilations.getByName("main") mainCompilation.cinterops.create("crashlytics") { - includeDirs("$projectDir/src/include") - compilerOpts("-DNS_FORMAT_ARGUMENT(A)=", "-D_Nullable_result=_Nullable") -// extraOpts("-mode", "sourcecode") + // Protocol-only header owned by the Swift package — no SDK symbols. + includeDirs("$rootDir/Sources/CrashKiOSCrashlyticsObjC/include") } } } diff --git a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt index d771bbb..6ed0ee9 100644 --- a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt +++ b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt @@ -1,13 +1,43 @@ +@file:OptIn(ExperimentalForeignApi::class) + package co.touchlab.crashkios.crashlytics -import com.rickclephas.kmp.nsexceptionkt.core.wrapUnhandledExceptionHook +import co.touchlab.crashkios.crashlytics.objc.CrashKiOSCrashlyticsSinkProtocol +import kotlinx.cinterop.ExperimentalForeignApi + +/** + * Registers the Swift-implemented [sink] that forwards CrashKiOS events to Firebase + * Crashlytics, and installs the unhandled-exception hook so unhandled Kotlin + * exceptions are logged as fatal crashes. + * + * Call once, from Swift, at startup — after `FirebaseApp.configure()` and before any + * Kotlin code runs: + * ```swift + * FirebaseApp.configure() + * CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) + * ``` + * `CrashlyticsSink` ships in the `CrashKiOSCrashlytics` Swift package (this repo's + * `Package.swift`), or copy it from the docs. Add + * `export("co.touchlab.crashkios:crashlytics")` to your framework configuration for + * clean unmangled names. + * + * Calling `enableCrashlytics()` (or constructing `CrashlyticsCallsActual`) on an Apple + * target WITHOUT having registered a sink fails fast with a descriptive error rather + * than silently dropping crash reports. + */ +public fun registerCrashlyticsSink(sink: CrashKiOSCrashlyticsSinkProtocol) { + crashlyticsRegistry.register(sink) { throwable -> + CrashlyticsKotlin.sendFatalException(throwable) + } + CrashlyticsKotlin.implementation = CrashlyticsCallsActual() +} /** - * Sets the unhandled exception hook such that all unhandled exceptions are logged to Crashlytics as fatal exceptions. - * If an unhandled exception hook was already set, that hook will be invoked after the exception is logged. - * Note: once the exception is logged the program will be terminated. - * @see wrapUnhandledExceptionHook + * The unhandled exception hook is now installed by [registerCrashlyticsSink]; this + * function does nothing. */ -public fun setCrashlyticsUnhandledExceptionHook(): Unit = wrapUnhandledExceptionHook { throwable -> - CrashlyticsKotlin.sendFatalException(throwable) +@Deprecated( + "registerCrashlyticsSink() installs the unhandled-exception hook; remove this call.", +) +public fun setCrashlyticsUnhandledExceptionHook() { } diff --git a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt index caf6ed5..f72a662 100644 --- a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt +++ b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt @@ -1,45 +1,45 @@ +@file:OptIn(ExperimentalForeignApi::class) + package co.touchlab.crashkios.crashlytics -import com.rickclephas.kmp.nsexceptionkt.core.asNSException -import com.rickclephas.kmp.nsexceptionkt.core.getFilteredStackTraceAddresses -import kotlinx.cinterop.UnsafeNumber -import kotlinx.cinterop.convert +import co.touchlab.crashkios.core.CrashSinkRegistry +import co.touchlab.crashkios.core.asNSException +import co.touchlab.crashkios.core.getFilteredStackTraceAddresses +import co.touchlab.crashkios.core.throwableName +import co.touchlab.crashkios.crashlytics.objc.CrashKiOSCrashlyticsSinkProtocol +import kotlinx.cinterop.ExperimentalForeignApi -@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) -actual class CrashlyticsCallsActual : CrashlyticsCalls { +internal val crashlyticsRegistry = CrashSinkRegistry("Crashlytics") - init { - FIRCheckLinkDependencies() - } +actual class CrashlyticsCallsActual : CrashlyticsCalls { + // Fail-fast: an implementation constructed without a registered Swift sink would + // silently lose every event. The pre-1.0 cinterop code failed just as loudly at + // this point via FIRCheckLinkDependencies when Firebase wasn't linked. + private val sink: CrashKiOSCrashlyticsSinkProtocol = crashlyticsRegistry.requireSink() actual override fun logMessage(message: String) { - FIRCrashlyticsLog(message) + sink.logMessage(message) } - @OptIn(UnsafeNumber::class) actual override fun sendHandledException(throwable: Throwable) { - val exceptionClassName = throwable::class.qualifiedName ?: throwable::class.simpleName ?: "kotlin.Throwable" - FIRCrashlyticsRecordHandledException( - exceptionClassName, - throwable.message ?: "", - throwable.getFilteredStackTraceAddresses().map { - FIRStackFrameWithAddress(it.convert()) - }, + sink.recordHandledExceptionWithName( + name = throwable.throwableName, + reason = throwable.message ?: "", + stackAddresses = throwable.getFilteredStackTraceAddresses(), ) } actual override fun sendFatalException(throwable: Throwable) { - val exception = throwable.asNSException(true) - // The recorded exception is persisted, so we can safely terminate afterwards. - // https://github.com/firebase/firebase-ios-sdk/blob/82f163bd86566f83c5d7572a1c2c0024a04eb4dc/Crashlytics/Crashlytics/Handlers/FIRCLSException.mm#L227 - tryFIRCLSExceptionRecordNSException(exception) + // The sink persists synchronously (FIRCLSExceptionRecordNSException), + // so the caller can safely terminate afterwards. + sink.recordFatalException(throwable.asNSException(true)) } actual override fun setCustomValue(key: String, value: Any) { - FIRCrashlyticsSetCustomValue(key, value) + sink.setCustomValue(value, forKey = key) } actual override fun setUserId(identifier: String) { - FIRCrashlyticsSetUserID(identifier) + sink.setUserId(identifier) } } diff --git a/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt b/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt new file mode 100644 index 0000000..1e1b4a1 --- /dev/null +++ b/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt @@ -0,0 +1,72 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package co.touchlab.crashkios.crashlytics + +import co.touchlab.crashkios.crashlytics.objc.CrashKiOSCrashlyticsSinkProtocol +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSException +import platform.darwin.NSObject + +// Kotlin stand-in for the Swift sink: exercises the same ObjC-protocol boundary and +// proves the module links with zero linker flags (the old -U / RND-91 failure mode). +private class FakeCrashlyticsSink : + NSObject(), + CrashKiOSCrashlyticsSinkProtocol { + val logs = mutableListOf() + val handled = mutableListOf>>() + val fatals = mutableListOf() + val custom = mutableMapOf() + var userId: String? = null + + override fun logMessage(message: String) { + logs += message + } + + override fun recordHandledExceptionWithName(name: String, reason: String, stackAddresses: List<*>) { + handled += Triple(name, reason, stackAddresses) + } + + override fun recordFatalException(exception: NSException) { + fatals += exception + } + + override fun setCustomValue(value: Any?, forKey: String) { + custom[forKey] = value + } + + override fun setUserId(identifier: String) { + userId = identifier + } +} + +class CrashlyticsSinkTest { + @Test + fun facadeForwardsToRegisteredSink() { + val sink = FakeCrashlyticsSink() + registerCrashlyticsSink(sink) + + CrashlyticsKotlin.logMessage("hello") + CrashlyticsKotlin.setCustomValue("answer", "42") + CrashlyticsKotlin.setUserId("user1") + CrashlyticsKotlin.sendHandledException(RuntimeException("boom")) + CrashlyticsKotlin.sendFatalException(RuntimeException("fatal", IllegalStateException("root cause"))) + + assertEquals(listOf("hello"), sink.logs) + assertEquals("42", sink.custom["answer"]?.toString()) + assertEquals("user1", sink.userId) + + val (name, reason, addresses) = sink.handled.single() + assertEquals("kotlin.RuntimeException", name) + assertEquals("boom", reason) + assertTrue(addresses.isNotEmpty(), "handled exception should carry stack addresses") + + val fatal = sink.fatals.single() + assertEquals("kotlin.RuntimeException", fatal.name) + assertTrue(fatal.reason!!.contains("fatal")) + assertTrue(fatal.reason!!.contains("Caused by: kotlin.IllegalStateException: root cause")) + assertTrue(fatal.callStackReturnAddresses.isNotEmpty(), "fatal exception should carry stack addresses") + } +} diff --git a/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt b/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt index f2f3e79..56a306e 100644 --- a/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt +++ b/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt @@ -27,8 +27,11 @@ object CrashlyticsKotlin { } /** - * Call in startup code in an actual app. Tests should generally skip this. In Kotlin/Native, not calling this - * for tests avoids linker issues. + * Call in startup code on Android. Tests should generally skip this. + * + * On Apple targets this alone is NOT enough: a Swift sink must be registered via + * `registerCrashlyticsSink()` (which also sets the implementation, making this call + * redundant there) — otherwise events are dropped with an NSLog warning. */ fun enableCrashlytics() { CrashlyticsKotlin.implementation = CrashlyticsCallsActual() diff --git a/crashlytics/src/nativeInterop/cinterop/crashlytics.def b/crashlytics/src/nativeInterop/cinterop/crashlytics.def index 9cb9892..3d4cc66 100644 --- a/crashlytics/src/nativeInterop/cinterop/crashlytics.def +++ b/crashlytics/src/nativeInterop/cinterop/crashlytics.def @@ -1,153 +1,9 @@ -package = co.touchlab.crashkios.crashlytics +package = co.touchlab.crashkios.crashlytics.objc language = Objective-C +headers = CrashKiOSCrashlyticsSink.h ---- - -#import - -extern void FIRCLSExceptionRecordNSException(NSException *exception) __attribute__((weak)); - -void __verifyFIRCLSExceptionRecordNSExceptionExists(void) { - if (!FIRCLSExceptionRecordNSException) { - @throw [NSException - exceptionWithName:@"FIRFunctionNotFound" - reason:@"Function 'FIRCLSExceptionRecordNSException' not available, make sure you're adding the Firebase Crashlytics dependency." - userInfo: nil - ]; - } -} - -void tryFIRCLSExceptionRecordNSException(NSException *exception) { - __verifyFIRCLSExceptionRecordNSExceptionExists(); - FIRCLSExceptionRecordNSException(exception); -} - -#define LoadClass(name)\ - static Class objClass;\ - if (!objClass) {\ - objClass = NSClassFromString(@OS_STRINGIFY(name));\ - if (!objClass) {\ - @throw [NSException\ - exceptionWithName:@"FIRClassNotFound"\ - reason: [NSString\ - stringWithFormat:@"Class '%@' not available, make sure you're adding the Firebase Crashlytics dependency.",\ - @OS_STRINGIFY(name)\ - ]\ - userInfo: nil\ - ];\ - }\ - } - -void* FIRMethodForSelector(id _Nonnull target, SEL _Nonnull selector) { - IMP method = [target methodForSelector:selector]; - if (!method) { - @throw [NSException - exceptionWithName:@"FIRMethodNotFound" - reason: [NSString - stringWithFormat:@"Target '%@' returned nil method for selector '%@'. This is probably a bug in CrashKiOS", - target, - NSStringFromSelector(selector) - ] - userInfo: nil - ]; - } else { - return method; - } -} - -Class FIRExceptionModelClass(void) { - LoadClass(FIRExceptionModel); - return objClass; -} - -Class FIRStackFrameClass(void) { - LoadClass(FIRStackFrame); - return objClass; -} - -Class FIRCrashlyticsClass(void) { - LoadClass(FIRCrashlytics); - return objClass; -} - -id FIRExceptionModelWithNameAndReason(NSString* _Nonnull name, NSString* _Nonnull reason) { - Class objClass = FIRExceptionModelClass(); - SEL selector = NSSelectorFromString(@"exceptionModelWithName:reason:"); - id (*exceptionModelWithNameAndReason)(id, SEL, NSString*, NSString*) = FIRMethodForSelector(objClass, selector); - return exceptionModelWithNameAndReason(objClass, selector, name, reason); -} - -void FIRExceptionModelSetStackTrace(id exceptionModel, NSArray* _Nonnull stackFrames) { - SEL selector = NSSelectorFromString(@"setStackTrace:"); - void (*setStackTrace)(id, SEL, NSArray*) = FIRMethodForSelector(exceptionModel, selector); - setStackTrace(exceptionModel, selector, stackFrames); -} - -id FIRStackFrameWithAddress(NSUInteger address) { - Class objClass = FIRStackFrameClass(); - SEL selector = NSSelectorFromString(@"stackFrameWithAddress:"); - id (*stackFrameWithAddress)(id, SEL, NSUInteger) = FIRMethodForSelector(objClass, selector); - return stackFrameWithAddress(objClass, selector, address); -} - -id _Nullable FIRCrashlyticsInstanceOrNull(void) { - Class objClass = FIRCrashlyticsClass(); - SEL selector = NSSelectorFromString(@"crashlytics"); - id (*crashlytics)(id, SEL) = FIRMethodForSelector(objClass, selector); - return crashlytics(objClass, selector); -} - -id _Nonnull FIRCrashlyticsInstance(void) { - id instance = FIRCrashlyticsInstanceOrNull(); - if (instance) { - return instance; - } else { - @throw [NSException - exceptionWithName:@"FIRCrashlyticsNil" - reason: @"[FirCrashlytics crashlytics] returned nil. Make sure you initialize Firebase before using it." - userInfo: nil - ]; - } -} - -void FIRCrashlyticsRecordExceptionModel(id crashlytics, id exceptionModel) { - SEL selector = NSSelectorFromString(@"recordExceptionModel:"); - void (*recordExceptionModel)(id, SEL, id) = FIRMethodForSelector(crashlytics, selector); - recordExceptionModel(crashlytics, selector, exceptionModel); -} - -void FIRCrashlyticsRecordHandledException(NSString* _Nonnull name, NSString* _Nonnull reason, NSArray* _Nonnull stackFrames) { - id exceptionModel = FIRExceptionModelWithNameAndReason(name, reason); - FIRExceptionModelSetStackTrace(exceptionModel, stackFrames); - FIRCrashlyticsRecordExceptionModel(FIRCrashlyticsInstance(), exceptionModel); -} - -void FIRCrashlyticsLog(NSString* _Nonnull message) { - id crashlytics = FIRCrashlyticsInstance(); - SEL selector = NSSelectorFromString(@"log:"); - void (*log)(id, SEL, NSString* _Nonnull) = FIRMethodForSelector(crashlytics, selector); - log(crashlytics, selector, message); -} - -void FIRCrashlyticsSetUserID(NSString* _Nonnull identifier) { - id crashlytics = FIRCrashlyticsInstance(); - SEL selector = NSSelectorFromString(@"setUserID:"); - void (*setUserID)(id, SEL, NSString* _Nonnull) = FIRMethodForSelector(crashlytics, selector); - setUserID(crashlytics, selector, identifier); -} - -void FIRCrashlyticsSetCustomValue(NSString* _Nonnull key, id __nullable value) { - id crashlytics = FIRCrashlyticsInstance(); - SEL selector = NSSelectorFromString(@"setCustomValue:forKey:"); - void (*setCustomValueForKey)(id, SEL, id __nullable, NSString* _Nonnull) = FIRMethodForSelector(crashlytics, selector); - setCustomValueForKey(crashlytics, selector, value, key); -} - -void FIRCheckLinkDependencies(void) { - __verifyFIRCLSExceptionRecordNSExceptionExists(); - - // Load classes to verify we have them all - FIRExceptionModelClass(); - FIRStackFrameClass(); - FIRCrashlyticsClass(); -} +# Protocol-only cinterop: the header (owned by the Swift package, see +# Sources/CrashKiOSCrashlyticsObjC/include/) declares no external classes or +# functions, so this emits NO undefined symbols — no -U flags, no cacheKind=none, +# no App Store strip hazard. The Swift app implements the protocol and registers +# it via registerCrashlyticsSink(). From 5381191304e89f31a11eeec08bfa7989efec2e74 Mon Sep 17 00:00:00 2001 From: Wellington Pereira Date: Wed, 8 Jul 2026 20:36:50 -0300 Subject: [PATCH 5/7] Remove linker-flag gradle plugins --- bugsnag-ios-link/build.gradle.kts | 68 ------------------ .../touchlab/crashkios/BugsnagLinkPlugin.kt | 72 ------------------- build.gradle.kts | 1 - crashlytics-ios-link/build.gradle.kts | 67 ----------------- .../crashkios/CrashlyticsLinkPlugin.kt | 67 ----------------- gradle/libs.versions.toml | 9 --- settings.gradle.kts | 2 - 7 files changed, 286 deletions(-) delete mode 100644 bugsnag-ios-link/build.gradle.kts delete mode 100644 bugsnag-ios-link/src/main/kotlin/co/touchlab/crashkios/BugsnagLinkPlugin.kt delete mode 100644 crashlytics-ios-link/build.gradle.kts delete mode 100644 crashlytics-ios-link/src/main/kotlin/co/touchlab/crashkios/CrashlyticsLinkPlugin.kt diff --git a/bugsnag-ios-link/build.gradle.kts b/bugsnag-ios-link/build.gradle.kts deleted file mode 100644 index cda6164..0000000 --- a/bugsnag-ios-link/build.gradle.kts +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright (c) 2022 Touchlab. - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ - -plugins { - `kotlin-dsl` - id("java-gradle-plugin") - id("com.vanniktech.maven.publish.base") - id("com.gradle.plugin-publish") -} - -repositories { - gradlePluginPortal() - mavenCentral() -} - -gradlePlugin { - website.set("https://github.com/touchlab/CrashKiOS") - vcsUrl.set("https://github.com/touchlab/CrashKiOS.git") - - description = "CrashKiOS linker params for Bugsnag on iOS" - plugins { - register("bugsnag-ios-link-plugin") { - id = "co.touchlab.crashkios.bugsnaglink" - implementationClass = "co.touchlab.crashkios.BugsnagLinkPlugin" - displayName = "Bugsnag iOS Link Plugin" - tags.set(listOf("kmm", "kotlin", "multiplatform", "mobile", "ios")) - } - } -} - -dependencies { - compileOnly(gradleApi()) - compileOnly(kotlin("gradle-plugin")) - testImplementation(kotlin("test")) -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(8)) - } -} - -@Suppress("ktlint:standard:property-naming") -val GROUP: String by project - -@Suppress("ktlint:standard:property-naming") -val VERSION_NAME: String by project - -group = GROUP -version = VERSION_NAME - -mavenPublishing { - publishToMavenCentral() - val releaseSigningEnabled = - project.properties["RELEASE_SIGNING_ENABLED"]?.toString()?.equals("false", ignoreCase = true) != true - if (releaseSigningEnabled) signAllPublications() - pomFromGradleProperties() -} diff --git a/bugsnag-ios-link/src/main/kotlin/co/touchlab/crashkios/BugsnagLinkPlugin.kt b/bugsnag-ios-link/src/main/kotlin/co/touchlab/crashkios/BugsnagLinkPlugin.kt deleted file mode 100644 index 915eb2a..0000000 --- a/bugsnag-ios-link/src/main/kotlin/co/touchlab/crashkios/BugsnagLinkPlugin.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright (c) 2022 Touchlab. - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ - -package co.touchlab.crashkios - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.kotlin.dsl.getByType -import org.jetbrains.kotlin.gradle.dsl.KotlinArtifactsExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinNativeFrameworkConfig -import org.jetbrains.kotlin.gradle.plugin.mpp.Framework -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -import org.jetbrains.kotlin.gradle.targets.native.tasks.artifact.kotlinArtifactsExtension - -internal val Project.kotlinExtension: KotlinMultiplatformExtension get() = extensions.getByType() - -@Suppress("unused") -class BugsnagLinkPlugin : Plugin { - override fun apply(project: Project): Unit = project.withKotlinMultiplatformPlugin { - val linkerArgs = - "-U _OBJC_CLASS_\$_BugsnagHandledState " + - "-U _OBJC_CLASS_\$_Bugsnag " + - "-U _OBJC_CLASS_\$_BugsnagStackframe " + - "-U _OBJC_CLASS_\$_FIRStackFrame " + - "-U _OBJC_CLASS_\$_BugsnagFeatureFlag " + - "-U _OBJC_CLASS_\$_BugsnagError" - afterEvaluate { - project.kotlinExtension.crashLinkerConfig(linkerArgs) - project.kotlinArtifactsExtension.crashLinkerConfigArtifacts(linkerArgs) - } - } -} - -private fun Project.withKotlinMultiplatformPlugin(action: Project.() -> Unit) { - pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { - action() - } -} - -private fun KotlinArtifactsExtension.crashLinkerConfigArtifacts(linkerOpts: String) { - artifactConfigs.withType(KotlinNativeFrameworkConfig::class.java).configureEach { - if (!isStatic) { - toolOptions { - freeCompilerArgs.add("-linker-options") - freeCompilerArgs.add(linkerOpts) - } - } - } -} - -private fun KotlinMultiplatformExtension.crashLinkerConfig(linkerOpts: String) { - targets.withType(KotlinNativeTarget::class.java).configureEach { - val hasDynamicFrameworks = binaries.any { it is Framework && !it.isStatic } - - if (hasDynamicFrameworks) { - compilations.getByName("main").compileTaskProvider.configure { - compilerOptions.freeCompilerArgs.addAll("-linker-options", linkerOpts) - } - } - } -} diff --git a/build.gradle.kts b/build.gradle.kts index 11fd7a5..d6d9eab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,6 @@ plugins { alias(libs.plugins.kotlin.multiplatform) apply false alias(libs.plugins.maven.publish) apply false alias(libs.plugins.android.library) apply false - alias(libs.plugins.gradle.publish) apply false id("org.jlleitschuh.gradle.ktlint") version "12.2.0" apply false } diff --git a/crashlytics-ios-link/build.gradle.kts b/crashlytics-ios-link/build.gradle.kts deleted file mode 100644 index 7b583b2..0000000 --- a/crashlytics-ios-link/build.gradle.kts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2022 Touchlab. - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ -plugins { - `kotlin-dsl` - id("java-gradle-plugin") - id("com.vanniktech.maven.publish.base") - id("com.gradle.plugin-publish") -} - -repositories { - gradlePluginPortal() - mavenCentral() -} - -gradlePlugin { - website.set("https://github.com/touchlab/CrashKiOS") - vcsUrl.set("https://github.com/touchlab/CrashKiOS.git") - - description = "CrashKiOS linker params for Crashlytics on iOS" - plugins { - register("crashlytics-ios-link-plugin") { - id = "co.touchlab.crashkios.crashlyticslink" - implementationClass = "co.touchlab.crashkios.CrashlyticsLinkPlugin" - displayName = "Crashlytics iOS Link Plugin" - tags.set(listOf("kmm", "kotlin", "multiplatform", "mobile", "ios")) - } - } -} - -dependencies { - compileOnly(gradleApi()) - compileOnly(kotlin("gradle-plugin")) - testImplementation(kotlin("test")) -} - -java { - toolchain { - languageVersion.set(JavaLanguageVersion.of(8)) - } -} - -@Suppress("ktlint:standard:property-naming") -val GROUP: String by project - -@Suppress("ktlint:standard:property-naming") -val VERSION_NAME: String by project - -group = GROUP -version = VERSION_NAME - -mavenPublishing { - publishToMavenCentral() - val releaseSigningEnabled = - project.properties["RELEASE_SIGNING_ENABLED"]?.toString()?.equals("false", ignoreCase = true) != true - if (releaseSigningEnabled) signAllPublications() - pomFromGradleProperties() -} diff --git a/crashlytics-ios-link/src/main/kotlin/co/touchlab/crashkios/CrashlyticsLinkPlugin.kt b/crashlytics-ios-link/src/main/kotlin/co/touchlab/crashkios/CrashlyticsLinkPlugin.kt deleted file mode 100644 index a59ebc5..0000000 --- a/crashlytics-ios-link/src/main/kotlin/co/touchlab/crashkios/CrashlyticsLinkPlugin.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (c) 2022 Touchlab. - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except - * in compliance with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License - * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express - * or implied. See the License for the specific language governing permissions and limitations under - * the License. - */ - -package co.touchlab.crashkios - -import org.gradle.api.Plugin -import org.gradle.api.Project -import org.gradle.kotlin.dsl.getByType -import org.jetbrains.kotlin.gradle.dsl.KotlinArtifactsExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.dsl.KotlinNativeFrameworkConfig -import org.jetbrains.kotlin.gradle.plugin.mpp.Framework -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget -import org.jetbrains.kotlin.gradle.targets.native.tasks.artifact.kotlinArtifactsExtension - -internal val Project.kotlinExtension: KotlinMultiplatformExtension get() = extensions.getByType() - -@Suppress("unused") -class CrashlyticsLinkPlugin : Plugin { - - override fun apply(project: Project): Unit = project.withKotlinMultiplatformPlugin { - val linkerArgs = "-U _FIRCLSExceptionRecordNSException" - afterEvaluate { - project.kotlinExtension.crashLinkerConfig(linkerArgs) - project.kotlinArtifactsExtension.crashLinkerConfigArtifacts(linkerArgs) - } - } -} - -private fun Project.withKotlinMultiplatformPlugin(action: Project.() -> Unit) { - pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { - action() - } -} - -private fun KotlinArtifactsExtension.crashLinkerConfigArtifacts(linkerOpts: String) { - artifactConfigs.withType(KotlinNativeFrameworkConfig::class.java).configureEach { - if (!isStatic) { - toolOptions { - freeCompilerArgs.add("-linker-options") - freeCompilerArgs.add(linkerOpts) - } - } - } -} - -private fun KotlinMultiplatformExtension.crashLinkerConfig(linkerOpts: String) { - targets.withType(KotlinNativeTarget::class.java).configureEach { - val hasDynamicFrameworks = binaries.any { it is Framework && !it.isStatic } - - if (hasDynamicFrameworks) { - compilations.getByName("main").compileTaskProvider.configure { - compilerOptions.freeCompilerArgs.addAll("-linker-options", linkerOpts) - } - } - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ecacc06..4922f2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,11 +9,8 @@ kotlin = "2.2.10" android-gradle-plugin = "8.10.0" mavenPublish = "0.29.0" touchlab-docusaurus-template = "0.1.10" -gradlePublish = "1.2.1" -nsexceptionKt = "0.1.10" firebase-crashlytics = "18.4.1" bugsnag = "5.31.1" -crashkios = "0.8.7" # Sample Apps androidx-core = "1.12.0" @@ -24,7 +21,6 @@ androidx-navigationUI = "2.7.2" androidx-coordinatorLayout = "1.2.0" [libraries] -nsexceptionKt-core = { module = "com.rickclephas.kmp:nsexception-kt-core", version.ref = "nsexceptionKt" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx",version.ref = "firebase-crashlytics" } bugsnag-android = { module = "com.bugsnag:bugsnag-android", version.ref = "bugsnag" } @@ -40,11 +36,6 @@ kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref maven-publish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } android-library = { id = "com.android.library", version.ref = "android-gradle-plugin" } touchlab-docusaurus-template = { id = "co.touchlab.touchlabtools.docusaurusosstemplate", version.ref = "touchlab-docusaurus-template" } -gradle-publish = { id = "com.gradle.plugin-publish", version.ref = "gradlePublish" } - -# For Samples -crashkios-crashlyticslink = { id = "co.touchlab.crashkios.crashlyticslink", version.ref = "crashkios" } -crashkios-bugsnaglink = { id = "co.touchlab.crashkios.bugsnaglink", version.ref = "crashkios" } [bundles] android = [ diff --git a/settings.gradle.kts b/settings.gradle.kts index a17dd5e..82d2ed0 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,8 +1,6 @@ include(":core") include(":bugsnag") include(":crashlytics") -include("bugsnag-ios-link") -include("crashlytics-ios-link") pluginManagement { repositories { From fc878be3438aedd0921ddc5068290ec45ade1aee Mon Sep 17 00:00:00 2001 From: Wellington Pereira Date: Wed, 8 Jul 2026 20:50:03 -0300 Subject: [PATCH 6/7] Update samples --- .../project.pbxproj | 2 + .../CrashKiOSSampleIOS/AppDelegate.swift | 79 ++- .../CrashKiOSSampleIOS/BridgingHeader.h | 18 + samples/sample-bugsnag/build.gradle.kts | 1 - samples/sample-bugsnag/gradle.properties | 1 - .../gradle/wrapper/gradle-wrapper.properties | 4 +- .../sample-bugsnag/shared/build.gradle.kts | 13 +- samples/sample-crashlytics/build.gradle.kts | 2 - samples/sample-crashlytics/gradle.properties | 1 - .../gradle/libs.versions.toml | 2 - .../ios-spm/ios.xcodeproj/project.pbxproj | 16 + .../ios-spm/ios/AppDelegate.swift | 6 +- .../ios/ios.xcodeproj/project.pbxproj | 449 ------------------ .../ios/ios/AppDelegate.swift | 40 +- .../ios/ios/BridgingHeader.h | 21 + .../sample-crashlytics/settings.gradle.kts | 2 - .../shared/build.gradle.kts | 15 +- .../co/touchlab/crashkiossample/Helper.kt | 12 +- 18 files changed, 196 insertions(+), 488 deletions(-) create mode 100644 samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/BridgingHeader.h delete mode 100644 samples/sample-crashlytics/ios/ios.xcodeproj/project.pbxproj create mode 100644 samples/sample-crashlytics/ios/ios/BridgingHeader.h diff --git a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS.xcodeproj/project.pbxproj b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS.xcodeproj/project.pbxproj index acc5c0a..b051d54 100644 --- a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS.xcodeproj/project.pbxproj +++ b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS.xcodeproj/project.pbxproj @@ -389,6 +389,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.CrashKiOSSampleIOS; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = CrashKiOSSampleIOS/BridgingHeader.h; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -410,6 +411,7 @@ ); PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.CrashKiOSSampleIOS; PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = CrashKiOSSampleIOS/BridgingHeader.h; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift index c0c4e7c..7aed64a 100644 --- a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift +++ b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift @@ -18,9 +18,10 @@ import Bugsnag class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - let config = BugsnagConfiguration.loadConfig() - BugsnagConfigKt.startBugsnag(config: config) + // Configures Bugsnag for Kotlin crash handling, starts it, and registers the + // sink with the Kotlin side (which installs the unhandled-exception hook). + let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) + BugsnagKt.registerBugsnagSink(sink: sink) return true } @@ -40,3 +41,75 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } +// Copy of the reference sink from the CrashKiOS Swift package +// (Sources/CrashKiOSBugsnag/BugsnagSink.swift), inlined + the protocol declared in +// BridgingHeader.h so the sample doesn't need the package wired into the Xcode +// project. Prefer consuming the CrashKiOSBugsnag package product in a real app. +final class BugsnagSink: NSObject, CrashKiOSBugsnagSink { + + /// Configures `config` to suppress the duplicate termination crash that follows a + /// recorded Kotlin fatal, starts Bugsnag, and returns the sink. + static func start(_ config: BugsnagConfiguration) -> BugsnagSink { + overrideOriginalUnhandledValue() + config.addOnSendError { event in + !event.unhandled || !event.featureFlags.contains { $0.name == kotlinCrashedFeatureFlag } + } + config.clearFeatureFlag(name: kotlinCrashedFeatureFlag) + Bugsnag.start(with: config) + return BugsnagSink() + } + + func leaveBreadcrumb(_ message: String) { + Bugsnag.leaveBreadcrumb(withMessage: message) + } + + func notify(with exceptions: [NSException], handled: Bool) { + guard let exception = exceptions.first else { return } + Bugsnag.notify(exception) { event in + if handled { + event.severity = .warning + } else { + event.unhandled = true + event.severity = .error + } + event.errors += exceptions.dropFirst().map(BugsnagError.init) + return true + } + } + + func addMetadata(_ value: Any, key: String, section: String) { + Bugsnag.addMetadata(value, key: key, section: section) + } + + func markFatalCrashRecorded() { + // Called only by the Kotlin unhandled-exception hook, right before termination — + // never on direct sendFatalException() calls, which would poison the session and + // make the OnSendError filter discard later genuine crashes. + Bugsnag.addFeatureFlag(name: kotlinCrashedFeatureFlag) + } +} + +/// Feature flag used to mark the Kotlin termination crash. +private let kotlinCrashedFeatureFlag = "crashkios.kotlin_crashed" + +/// In Bugsnag 6.26.2+ the `originalUnhandledValue` property prevents our synthetic +/// unhandled exceptions from being stored to disk; alias it to `unhandled`. +/// https://github.com/bugsnag/bugsnag-cocoa/pull/1549 +private func overrideOriginalUnhandledValue() { + guard let handledStateClass = NSClassFromString("BugsnagHandledState"), + let originalMethod = class_getInstanceMethod(handledStateClass, NSSelectorFromString("originalUnhandledValue")), + let method = class_getInstanceMethod(handledStateClass, NSSelectorFromString("unhandled")) + else { return } + method_setImplementation(originalMethod, method_getImplementation(method)) +} + +private extension BugsnagError { + /// Creates a BugsnagError from a (Kotlin-synthesized) NSException. + convenience init(_ exception: NSException) { + self.init() + errorClass = exception.name.rawValue + errorMessage = exception.reason + stacktrace = BugsnagStackframe.stackframes(withCallStackReturnAddresses: exception.callStackReturnAddresses) + type = .cocoa + } +} diff --git a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/BridgingHeader.h b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/BridgingHeader.h new file mode 100644 index 0000000..f3a16c3 --- /dev/null +++ b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/BridgingHeader.h @@ -0,0 +1,18 @@ +#ifndef BridgingHeader_h +#define BridgingHeader_h + +// Copy of Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h. +// The Kotlin framework's umbrella header only FORWARD-declares this protocol +// (`@protocol CrashKiOSBugsnagSink;`), so Swift needs the real declaration to +// conform to it. Apps consuming the CrashKiOSBugsnag Swift package get it from +// the package and don't need this file. +#import + +@protocol CrashKiOSBugsnagSink +- (void)leaveBreadcrumb:(NSString * _Nonnull)message; +- (void)notifyWithExceptions:(NSArray * _Nonnull)exceptions handled:(BOOL)handled; +- (void)addMetadata:(id _Nonnull)value key:(NSString * _Nonnull)key section:(NSString * _Nonnull)section; +- (void)markFatalCrashRecorded; +@end + +#endif /* BridgingHeader_h */ diff --git a/samples/sample-bugsnag/build.gradle.kts b/samples/sample-bugsnag/build.gradle.kts index 6e2446c..0af2556 100644 --- a/samples/sample-bugsnag/build.gradle.kts +++ b/samples/sample-bugsnag/build.gradle.kts @@ -10,7 +10,6 @@ plugins { alias(projectLibs.plugins.kotlin.multiplatform) apply false alias(projectLibs.plugins.android.library) apply false - alias(projectLibs.plugins.crashkios.bugsnaglink) apply false alias(libs.plugins.bugsnag.gradle.plugin) apply false } diff --git a/samples/sample-bugsnag/gradle.properties b/samples/sample-bugsnag/gradle.properties index ddadbae..8f4c85c 100644 --- a/samples/sample-bugsnag/gradle.properties +++ b/samples/sample-bugsnag/gradle.properties @@ -13,4 +13,3 @@ android.useAndroidX=true org.gradle.jvmargs=-Xmx4g -kotlin.native.cacheKind=none \ No newline at end of file diff --git a/samples/sample-bugsnag/gradle/wrapper/gradle-wrapper.properties b/samples/sample-bugsnag/gradle/wrapper/gradle-wrapper.properties index e411586..e2847c8 100644 --- a/samples/sample-bugsnag/gradle/wrapper/gradle-wrapper.properties +++ b/samples/sample-bugsnag/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/sample-bugsnag/shared/build.gradle.kts b/samples/sample-bugsnag/shared/build.gradle.kts index 557662d..62b2332 100644 --- a/samples/sample-bugsnag/shared/build.gradle.kts +++ b/samples/sample-bugsnag/shared/build.gradle.kts @@ -7,13 +7,12 @@ * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") kotlin("multiplatform") kotlin("native.cocoapods") - id("co.touchlab.crashkios.bugsnaglink") } android { @@ -28,14 +27,14 @@ android { } } -tasks.withType { - kotlinOptions.jvmTarget = "1.8" -} - version = "0.0.1" kotlin { - androidTarget() + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } iosX64() iosArm64() // Note: iosSimulatorArm64 target requires that all dependencies have M1 support diff --git a/samples/sample-crashlytics/build.gradle.kts b/samples/sample-crashlytics/build.gradle.kts index 845045f..9495602 100644 --- a/samples/sample-crashlytics/build.gradle.kts +++ b/samples/sample-crashlytics/build.gradle.kts @@ -15,13 +15,11 @@ buildscript { dependencies { classpath(libs.google.services) classpath(libs.firebase.crashlytics.gradle) - classpath(libs.crashkios.utils) } } plugins { alias(projectLibs.plugins.kotlin.multiplatform) apply false alias(projectLibs.plugins.android.library) apply false - alias(projectLibs.plugins.crashkios.crashlyticslink) apply false } allprojects{ diff --git a/samples/sample-crashlytics/gradle.properties b/samples/sample-crashlytics/gradle.properties index bff3c4c..096f6ce 100644 --- a/samples/sample-crashlytics/gradle.properties +++ b/samples/sample-crashlytics/gradle.properties @@ -13,4 +13,3 @@ android.useAndroidX=true org.gradle.jvmargs=-Xmx4g -kotlin.native.cacheKind=none diff --git a/samples/sample-crashlytics/gradle/libs.versions.toml b/samples/sample-crashlytics/gradle/libs.versions.toml index 47609e0..a912d6b 100644 --- a/samples/sample-crashlytics/gradle/libs.versions.toml +++ b/samples/sample-crashlytics/gradle/libs.versions.toml @@ -2,7 +2,6 @@ firebase-bom = "32.2.3" firebase-crashlytics-gradle = "2.9.9" google-services = "4.3.15" -utils = "0.7.1-alpha3" [libraries] firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } @@ -10,4 +9,3 @@ firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx" } firebase-crashlytics-gradle = { module = "com.google.firebase:firebase-crashlytics-gradle", version.ref = "firebase-crashlytics-gradle" } google-services = { module = "com.google.gms:google-services", version.ref = "google-services" } -crashkios-utils = { module = "co.touchlab.crashkios:utils", version.ref = "utils" } diff --git a/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj b/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj index 5228e7f..aa9c537 100644 --- a/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj +++ b/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 1814D0362C2C8B2A00C93985 /* CrashlyticsCallsActual.kt in Resources */ = {isa = PBXBuildFile; fileRef = 1814D0352C2C8B2A00C93985 /* CrashlyticsCallsActual.kt */; }; 1814D0382C2C95A400C93985 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 1814D0372C2C95A400C93985 /* FirebaseCrashlytics */; }; + AAC4A5110000000000000003 /* CrashKiOSCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = AAC4A5110000000000000002 /* CrashKiOSCrashlytics */; }; 6381FF9E277D0BE900EF27B0 /* iosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FF9D277D0BE900EF27B0 /* iosApp.swift */; }; 6381FFA0277D0BE900EF27B0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FF9F277D0BE900EF27B0 /* ContentView.swift */; }; 6381FFA2277D0BEC00EF27B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6381FFA1277D0BEC00EF27B0 /* Assets.xcassets */; }; @@ -35,6 +36,7 @@ buildActionMask = 2147483647; files = ( 1814D0382C2C95A400C93985 /* FirebaseCrashlytics in Frameworks */, + AAC4A5110000000000000003 /* CrashKiOSCrashlytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -108,6 +110,7 @@ name = ios; packageProductDependencies = ( 1814D0372C2C95A400C93985 /* FirebaseCrashlytics */, + AAC4A5110000000000000002 /* CrashKiOSCrashlytics */, ); productName = ios; productReference = 6381FF9A277D0BE900EF27B0 /* ios.app */; @@ -139,6 +142,7 @@ mainGroup = 6381FF91277D0BE900EF27B0; packageReferences = ( 1814D0312C2C5B6D00C93985 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + AAC4A5110000000000000001 /* XCLocalSwiftPackageReference "../../.." */, ); productRefGroup = 6381FF9B277D0BE900EF27B0 /* Products */; projectDirPath = ""; @@ -432,6 +436,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AAC4A5110000000000000001 /* XCLocalSwiftPackageReference "../../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../../.."; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 1814D0312C2C5B6D00C93985 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; @@ -449,6 +460,11 @@ package = 1814D0312C2C5B6D00C93985 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseCrashlytics; }; + AAC4A5110000000000000002 /* CrashKiOSCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = AAC4A5110000000000000001 /* XCLocalSwiftPackageReference "../../.." */; + productName = CrashKiOSCrashlytics; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 6381FF92277D0BE900EF27B0 /* Project object */; diff --git a/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift b/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift index 586ba34..013a536 100644 --- a/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift +++ b/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift @@ -9,13 +9,15 @@ import UIKit import shared import Firebase +import CrashKiOSCrashlytics class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. FirebaseApp.configure() - HelperKt.startCrashKiOS() + // Registers the Crashlytics sink (from the CrashKiOSCrashlytics Swift package) + // and installs the Kotlin unhandled-exception hook. + CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) return true } diff --git a/samples/sample-crashlytics/ios/ios.xcodeproj/project.pbxproj b/samples/sample-crashlytics/ios/ios.xcodeproj/project.pbxproj deleted file mode 100644 index 24fd8ef..0000000 --- a/samples/sample-crashlytics/ios/ios.xcodeproj/project.pbxproj +++ /dev/null @@ -1,449 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 55; - objects = { - -/* Begin PBXBuildFile section */ - 6381FF9E277D0BE900EF27B0 /* iosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FF9D277D0BE900EF27B0 /* iosApp.swift */; }; - 6381FFA0277D0BE900EF27B0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FF9F277D0BE900EF27B0 /* ContentView.swift */; }; - 6381FFA2277D0BEC00EF27B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6381FFA1277D0BEC00EF27B0 /* Assets.xcassets */; }; - 6381FFA5277D0BEC00EF27B0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6381FFA4277D0BEC00EF27B0 /* Preview Assets.xcassets */; }; - 6381FFAE277D0E1200EF27B0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FFAC277D0E1200EF27B0 /* AppDelegate.swift */; }; - A30FA4F028DB17F400E4D5A3 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A30FA4EF28DB17F400E4D5A3 /* GoogleService-Info.plist */; }; - E3B345D7F7CB0D13E629AF26 /* Pods_ios.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 091D28CBC7DE88C8F19D392C /* Pods_ios.framework */; }; -/* End PBXBuildFile section */ - -/* Begin PBXFileReference section */ - 091D28CBC7DE88C8F19D392C /* Pods_ios.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_ios.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 405382DDAE0F912DC3FF8AE5 /* Pods-ios.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ios.release.xcconfig"; path = "Target Support Files/Pods-ios/Pods-ios.release.xcconfig"; sourceTree = ""; }; - 6381FF9A277D0BE900EF27B0 /* ios.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ios.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 6381FF9D277D0BE900EF27B0 /* iosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iosApp.swift; sourceTree = ""; }; - 6381FF9F277D0BE900EF27B0 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 6381FFA1277D0BEC00EF27B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 6381FFA4277D0BEC00EF27B0 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 6381FFAC277D0E1200EF27B0 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - A30FA4EF28DB17F400E4D5A3 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; - BB62DCDD1B787390B57E1E4C /* Pods-ios.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ios.debug.xcconfig"; path = "Target Support Files/Pods-ios/Pods-ios.debug.xcconfig"; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 6381FF97277D0BE900EF27B0 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - E3B345D7F7CB0D13E629AF26 /* Pods_ios.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 49A71BEE2A96A888E85A2092 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 091D28CBC7DE88C8F19D392C /* Pods_ios.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - 57FACC08B222F92CCEF68F9D /* Pods */ = { - isa = PBXGroup; - children = ( - BB62DCDD1B787390B57E1E4C /* Pods-ios.debug.xcconfig */, - 405382DDAE0F912DC3FF8AE5 /* Pods-ios.release.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; - 6381FF91277D0BE900EF27B0 = { - isa = PBXGroup; - children = ( - 6381FF9C277D0BE900EF27B0 /* ios */, - 6381FF9B277D0BE900EF27B0 /* Products */, - 57FACC08B222F92CCEF68F9D /* Pods */, - 49A71BEE2A96A888E85A2092 /* Frameworks */, - ); - sourceTree = ""; - }; - 6381FF9B277D0BE900EF27B0 /* Products */ = { - isa = PBXGroup; - children = ( - 6381FF9A277D0BE900EF27B0 /* ios.app */, - ); - name = Products; - sourceTree = ""; - }; - 6381FF9C277D0BE900EF27B0 /* ios */ = { - isa = PBXGroup; - children = ( - 6381FFAC277D0E1200EF27B0 /* AppDelegate.swift */, - A30FA4EF28DB17F400E4D5A3 /* GoogleService-Info.plist */, - 6381FF9D277D0BE900EF27B0 /* iosApp.swift */, - 6381FF9F277D0BE900EF27B0 /* ContentView.swift */, - 6381FFA1277D0BEC00EF27B0 /* Assets.xcassets */, - 6381FFA3277D0BEC00EF27B0 /* Preview Content */, - ); - path = ios; - sourceTree = ""; - }; - 6381FFA3277D0BEC00EF27B0 /* Preview Content */ = { - isa = PBXGroup; - children = ( - 6381FFA4277D0BEC00EF27B0 /* Preview Assets.xcassets */, - ); - path = "Preview Content"; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 6381FF99277D0BE900EF27B0 /* ios */ = { - isa = PBXNativeTarget; - buildConfigurationList = 6381FFA8277D0BEC00EF27B0 /* Build configuration list for PBXNativeTarget "ios" */; - buildPhases = ( - 1FA908E0CC375ABAF0A13713 /* [CP] Check Pods Manifest.lock */, - 6381FF96277D0BE900EF27B0 /* Sources */, - 6381FF97277D0BE900EF27B0 /* Frameworks */, - 6381FF98277D0BE900EF27B0 /* Resources */, - 6755B982E6A25ABA23A6DDB7 /* [CP] Embed Pods Frameworks */, - 6381FFAF277D0F0500EF27B0 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = ios; - productName = ios; - productReference = 6381FF9A277D0BE900EF27B0 /* ios.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 6381FF92277D0BE900EF27B0 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1300; - LastUpgradeCheck = 1300; - TargetAttributes = { - 6381FF99277D0BE900EF27B0 = { - CreatedOnToolsVersion = 13.0; - }; - }; - }; - buildConfigurationList = 6381FF95277D0BE900EF27B0 /* Build configuration list for PBXProject "ios" */; - compatibilityVersion = "Xcode 13.0"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 6381FF91277D0BE900EF27B0; - productRefGroup = 6381FF9B277D0BE900EF27B0 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 6381FF99277D0BE900EF27B0 /* ios */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 6381FF98277D0BE900EF27B0 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 6381FFA5277D0BEC00EF27B0 /* Preview Assets.xcassets in Resources */, - 6381FFA2277D0BEC00EF27B0 /* Assets.xcassets in Resources */, - A30FA4F028DB17F400E4D5A3 /* GoogleService-Info.plist in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 1FA908E0CC375ABAF0A13713 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-ios-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 6381FFAF277D0F0500EF27B0 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", - "$(SRCROOT)/$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; - }; - 6755B982E6A25ABA23A6DDB7 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ios/Pods-ios-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-ios/Pods-ios-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-ios/Pods-ios-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 6381FF96277D0BE900EF27B0 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 6381FFAE277D0E1200EF27B0 /* AppDelegate.swift in Sources */, - 6381FFA0277D0BE900EF27B0 /* ContentView.swift in Sources */, - 6381FF9E277D0BE900EF27B0 /* iosApp.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin XCBuildConfiguration section */ - 6381FFA6277D0BEC00EF27B0 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 6381FFA7277D0BEC00EF27B0 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MTL_ENABLE_DEBUG_INFO = NO; - MTL_FAST_MATH = YES; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 6381FFA9277D0BEC00EF27B0 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = BB62DCDD1B787390B57E1E4C /* Pods-ios.debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 00101; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_ASSET_PATHS = "\"ios/Preview Content\""; - DEVELOPMENT_TEAM = 8UD86646U9; - ENABLE_BITCODE = NO; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.1; - PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.crashkios.sample; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 6381FFAA277D0BEC00EF27B0 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 405382DDAE0F912DC3FF8AE5 /* Pods-ios.release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 00101; - DEVELOPMENT_ASSET_PATHS = "\"ios/Preview Content\""; - DEVELOPMENT_TEAM = 8UD86646U9; - ENABLE_BITCODE = NO; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - MARKETING_VERSION = 0.1.1; - PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.crashkios.sample; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 6381FF95277D0BE900EF27B0 /* Build configuration list for PBXProject "ios" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 6381FFA6277D0BEC00EF27B0 /* Debug */, - 6381FFA7277D0BEC00EF27B0 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 6381FFA8277D0BEC00EF27B0 /* Build configuration list for PBXNativeTarget "ios" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 6381FFA9277D0BEC00EF27B0 /* Debug */, - 6381FFAA277D0BEC00EF27B0 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 6381FF92277D0BE900EF27B0 /* Project object */; -} diff --git a/samples/sample-crashlytics/ios/ios/AppDelegate.swift b/samples/sample-crashlytics/ios/ios/AppDelegate.swift index 586ba34..310d2e6 100644 --- a/samples/sample-crashlytics/ios/ios/AppDelegate.swift +++ b/samples/sample-crashlytics/ios/ios/AppDelegate.swift @@ -9,14 +9,50 @@ import UIKit import shared import Firebase +import FirebaseCrashlytics class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. FirebaseApp.configure() - HelperKt.startCrashKiOS() + // Registers the Crashlytics sink and installs the Kotlin unhandled-exception hook. + CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) return true } } + +// Copy of the reference sink from the CrashKiOS Swift package +// (Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift), inlined + the protocol +// declared in BridgingHeader.h so the sample doesn't need the package wired into +// the Xcode project. Prefer consuming the package product in a real app (see the +// ios-spm sample). +final class CrashlyticsSink: NSObject, CrashKiOSCrashlyticsSink { + + func logMessage(_ message: String) { + Crashlytics.crashlytics().log(message) + } + + func recordHandledException(withName name: String, reason: String, stackAddresses: [NSNumber]) { + let model = ExceptionModel(name: name, reason: reason) + model.stackTrace = stackAddresses.map { StackFrame(address: $0.uintValue) } + Crashlytics.crashlytics().record(exceptionModel: model) + } + + func recordFatalException(_ exception: NSException) { + FIRCLSExceptionRecordNSException(exception) + } + + func setCustomValue(_ value: Any?, forKey key: String) { + Crashlytics.crashlytics().setCustomValue(value as Any, forKey: key) + } + + func setUserId(_ identifier: String) { + Crashlytics.crashlytics().setUserID(identifier) + } +} + +// Private Crashlytics SPI that records a FATAL exception and persists it synchronously. +// Resolved hard at app link time against the FirebaseCrashlytics binary. +@_silgen_name("FIRCLSExceptionRecordNSException") +private func FIRCLSExceptionRecordNSException(_ exception: NSException) diff --git a/samples/sample-crashlytics/ios/ios/BridgingHeader.h b/samples/sample-crashlytics/ios/ios/BridgingHeader.h new file mode 100644 index 0000000..0949212 --- /dev/null +++ b/samples/sample-crashlytics/ios/ios/BridgingHeader.h @@ -0,0 +1,21 @@ +#ifndef BridgingHeader_h +#define BridgingHeader_h + +// Copy of Sources/CrashKiOSCrashlyticsObjC/include/CrashKiOSCrashlyticsSink.h. +// The Kotlin framework's umbrella header only FORWARD-declares this protocol +// (`@protocol CrashKiOSCrashlyticsSink;`), so Swift needs the real declaration to +// conform to it. Apps consuming the CrashKiOSCrashlytics Swift package get it from +// the package (see the ios-spm sample) and don't need this file. +#import + +@protocol CrashKiOSCrashlyticsSink +- (void)logMessage:(NSString * _Nonnull)message; +- (void)recordHandledExceptionWithName:(NSString * _Nonnull)name + reason:(NSString * _Nonnull)reason + stackAddresses:(NSArray * _Nonnull)addresses; +- (void)recordFatalException:(NSException * _Nonnull)exception; +- (void)setCustomValue:(id _Nullable)value forKey:(NSString * _Nonnull)key; +- (void)setUserId:(NSString * _Nonnull)identifier; +@end + +#endif /* BridgingHeader_h */ diff --git a/samples/sample-crashlytics/settings.gradle.kts b/samples/sample-crashlytics/settings.gradle.kts index 98a4d1f..be65919 100644 --- a/samples/sample-crashlytics/settings.gradle.kts +++ b/samples/sample-crashlytics/settings.gradle.kts @@ -22,8 +22,6 @@ includeBuild("../..") { dependencySubstitution { substitute(module("co.touchlab.crashkios:crashlytics")) .using(project(":crashlytics")).because("we want to auto-wire up sample dependency") - substitute(module("co.touchlab.crashkios.crashlyticslink:co.touchlab.crashkios.crashlyticslink.gradle.plugin")) - .using(project(":crashlytics-ios-link")).because("we want to auto-wire up sample dependency") } } diff --git a/samples/sample-crashlytics/shared/build.gradle.kts b/samples/sample-crashlytics/shared/build.gradle.kts index 74aa206..b5d5aa5 100644 --- a/samples/sample-crashlytics/shared/build.gradle.kts +++ b/samples/sample-crashlytics/shared/build.gradle.kts @@ -7,13 +7,12 @@ * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.library") kotlin("multiplatform") kotlin("native.cocoapods") - id("co.touchlab.crashkios.crashlyticslink") } android { @@ -28,14 +27,14 @@ android { } } -tasks.withType { - kotlinOptions.jvmTarget = "1.8" -} - version = "0.1.2" kotlin { - androidTarget() + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } iosX64() iosArm64() // Note: iosSimulatorArm64 target requires that all dependencies have M1 support @@ -61,6 +60,8 @@ kotlin { homepage = "https://www.touchlab.co" ios.deploymentTarget = "14.1" framework { + // Expose registerCrashlyticsSink + the sink protocol to Swift with clean names. + export("co.touchlab.crashkios:crashlytics") isStatic = false } } diff --git a/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt b/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt index 3d7a330..6a1d3ca 100644 --- a/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt +++ b/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt @@ -10,11 +10,7 @@ package co.touchlab.crashkiossample -import co.touchlab.crashkios.crashlytics.enableCrashlytics -import co.touchlab.crashkios.crashlytics.setCrashlyticsUnhandledExceptionHook - -@Suppress("unused") -fun startCrashKiOS(){ - enableCrashlytics() - setCrashlyticsUnhandledExceptionHook() -} +// CrashKiOS setup moved to Swift: the app registers a CrashlyticsSink via +// registerCrashlyticsSink() in AppDelegate (see ios/ios/AppDelegate.swift), +// which also installs the Kotlin unhandled-exception hook. +// No Kotlin-side startup call is needed anymore. From 3e6003e41123c4770ad18ad1331d2477931544af Mon Sep 17 00:00:00 2001 From: Wellington Pereira Date: Fri, 17 Jul 2026 18:47:06 -0300 Subject: [PATCH 7/7] Implement a single API for configuration --- .gitignore | 1 + Sources/CrashKiOSBugsnag/BugsnagSink.swift | 7 +- .../CrashlyticsSink.swift | 8 +- bugsnag/build.gradle.kts | 2 +- .../co/touchlab/crashkios/bugsnag/Bugsnag.kt | 26 +++--- .../crashkios/bugsnag/BugsnagSinkTest.kt | 90 ++++++++----------- .../crashkios/bugsnag/BugsnagKotlin.kt | 7 +- .../co/touchlab/crashkios/core/CrashKiOS.kt | 27 ++++++ .../crashkios/core/CrashSinkRegistry.kt | 5 +- .../core/ThrowableNSExceptionTest.kt | 2 +- crashlytics/build.gradle.kts | 2 +- .../crashkios/crashlytics/Crashlytics.kt | 31 ++++--- .../crashlytics/CrashlyticsSinkTest.kt | 61 +++++++------ .../crashlytics/CrashlyticsKotlin.kt | 4 - .../CrashKiOSSampleIOS/AppDelegate.swift | 6 +- .../sample-bugsnag/shared/build.gradle.kts | 1 + .../ios-spm/ios.xcodeproj/project.pbxproj | 10 +-- .../ios-spm/ios/AppDelegate.swift | 6 +- .../ios/ios/AppDelegate.swift | 5 +- .../shared/build.gradle.kts | 3 +- .../co/touchlab/crashkiossample/Helper.kt | 16 ---- 21 files changed, 157 insertions(+), 163 deletions(-) create mode 100644 core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashKiOS.kt delete mode 100644 samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt diff --git a/.gitignore b/.gitignore index 4440276..b9ed011 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ buildboth.sh Pods .build/ Package.resolved +.swiftpm/ diff --git a/Sources/CrashKiOSBugsnag/BugsnagSink.swift b/Sources/CrashKiOSBugsnag/BugsnagSink.swift index def14d0..8180e6a 100644 --- a/Sources/CrashKiOSBugsnag/BugsnagSink.swift +++ b/Sources/CrashKiOSBugsnag/BugsnagSink.swift @@ -26,12 +26,13 @@ public func configureBugsnagForKotlin(_ config: BugsnagConfiguration) { /// Reference `CrashKiOSBugsnagSink` implementation. /// /// Start Bugsnag through this class so the configuration is prepared for Kotlin -/// crash handling, then register the returned sink with the Kotlin side: +/// crash handling, then configure CrashKiOS with the returned sink: /// ```swift /// let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) -/// BugsnagKt.registerBugsnagSink(sink: sink) +/// CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) /// ``` -/// (Add `export("co.touchlab.crashkios:bugsnag")` to the Kotlin framework for clean names.) +/// (Add `export("co.touchlab.crashkios:bugsnag")` AND `export("co.touchlab.crashkios:core")` +/// to the Kotlin framework for clean names.) public final class BugsnagSink: NSObject, CrashKiOSBugsnagSink { /// Configures `config` via `configureBugsnagForKotlin(_:)`, starts Bugsnag, diff --git a/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift b/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift index 3890313..495abc1 100644 --- a/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift +++ b/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift @@ -4,14 +4,14 @@ import FirebaseCrashlytics /// Reference `CrashKiOSCrashlyticsSink` implementation. /// -/// Register from your `AppDelegate`, right after Firebase is configured and before +/// Configure from your `AppDelegate`, right after Firebase is configured and before /// any Kotlin code runs: /// ```swift /// FirebaseApp.configure() -/// CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) +/// CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: CrashlyticsSink())) /// ``` -/// (The Kotlin function name depends on your framework's export configuration; add -/// `export("co.touchlab.crashkios:crashlytics")` to the framework for clean names.) +/// (Add `export("co.touchlab.crashkios:crashlytics")` AND `export("co.touchlab.crashkios:core")` +/// to the framework for clean names.) public final class CrashlyticsSink: NSObject, CrashKiOSCrashlyticsSink { private let crashlytics: Crashlytics diff --git a/bugsnag/build.gradle.kts b/bugsnag/build.gradle.kts index 9baec00..c9125bf 100644 --- a/bugsnag/build.gradle.kts +++ b/bugsnag/build.gradle.kts @@ -55,7 +55,7 @@ kotlin { sourceSets { commonMain { dependencies { - implementation(project(":core")) + api(project(":core")) } } commonTest { diff --git a/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt index 632d4a2..3f26a53 100644 --- a/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt +++ b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/Bugsnag.kt @@ -3,37 +3,35 @@ package co.touchlab.crashkios.bugsnag import co.touchlab.crashkios.bugsnag.objc.CrashKiOSBugsnagSinkProtocol +import co.touchlab.crashkios.core.CrashReportingImplementation import kotlinx.cinterop.ExperimentalForeignApi /** - * Registers the Swift-implemented [sink] that forwards CrashKiOS events to Bugsnag, - * and installs the unhandled-exception hook so unhandled Kotlin exceptions are - * reported as fatal crashes. + * The Bugsnag backend for [co.touchlab.crashkios.core.CrashKiOS.configure]. Wraps the + * Swift-implemented [sink] that forwards CrashKiOS events to Bugsnag. * * Call once, from Swift, at startup. Start Bugsnag through the shipped sink so the * configuration suppresses the duplicate termination crash: * ```swift * let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) - * BugsnagKt.registerBugsnagSink(sink: sink) + * CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) * ``` * `BugsnagSink` ships in the `CrashKiOSBugsnag` Swift package (this repo's * `Package.swift`); custom sink implementations must call * `configureBugsnagForKotlin(config)` (from the same package) before `Bugsnag.start` - * to keep the duplicate-crash suppression. Add - * `export("co.touchlab.crashkios:bugsnag")` to your framework configuration for + * to keep the duplicate-crash suppression. Add `export("co.touchlab.crashkios:bugsnag")` + * AND `export("co.touchlab.crashkios:core")` to your framework configuration for * clean unmangled names. * - * Replaces `startBugsnag()` / `configureBugsnag()` / `setBugsnagUnhandledExceptionHook()`, - * which were removed along with the Bugsnag cinterop — Bugsnag start and configuration - * now live in Swift. - * * Calling `enableBugsnag()` (or constructing `BugsnagCallsActual`) on an Apple target - * WITHOUT having registered a sink fails fast with a descriptive error rather than + * WITHOUT having configured a sink fails fast with a descriptive error rather than * silently dropping crash reports. */ -public fun registerBugsnagSink(sink: CrashKiOSBugsnagSinkProtocol) { - bugsnagRegistry.register(sink, fatalHook(sink)) - BugsnagKotlin.implementation = BugsnagCallsActual() +public class BugsnagCrashReporting(private val sink: CrashKiOSBugsnagSinkProtocol) : CrashReportingImplementation { + override fun install() { + bugsnagRegistry.register(sink, fatalHook(sink)) + BugsnagKotlin.implementation = BugsnagCallsActual() + } } /** diff --git a/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt b/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt index 8f93565..b53155b 100644 --- a/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt +++ b/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt @@ -3,6 +3,7 @@ package co.touchlab.crashkios.bugsnag import co.touchlab.crashkios.bugsnag.objc.CrashKiOSBugsnagSinkProtocol +import co.touchlab.crashkios.core.CrashKiOS import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -10,50 +11,11 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSException import platform.darwin.NSObject -// Kotlin stand-in for the Swift sink: exercises the same ObjC-protocol boundary and -// proves the module links with zero linker flags (the old -U / RND-91 failure mode). -private class FakeBugsnagSink : - NSObject(), - CrashKiOSBugsnagSinkProtocol { - val breadcrumbs = mutableListOf() - val notifications = mutableListOf, Boolean>>() - val metadata = mutableListOf>() - var fatalMarks = 0 - - // Call order across notify/mark — proves the hook sequences them correctly, - // not just that both eventually happened. - val events = mutableListOf() - - override fun leaveBreadcrumb(message: String) { - breadcrumbs += message - } - - override fun markFatalCrashRecorded() { - fatalMarks++ - events += "mark" - } - - override fun notifyWithExceptions(exceptions: List<*>, handled: Boolean) { - notifications += exceptions to handled - events += "notify" - } - - override fun addMetadata(value: Any, key: String, section: String) { - metadata += Triple(value, key, section) - } -} - class BugsnagSinkTest { - // registerBugsnagSink() mutates the module-level `bugsnagRegistry` singleton. Safe - // to call from every test that needs BugsnagKotlin's facade routed to its own sink - // (sinkRef always overwrites) — NOT safe to rely on for the real unhandled-exception - // hook actually firing with a given test's sink: hook-install is a once-ever guard, - // permanently bound to whichever sink registered first. That's why fatalHookNotifiesThenMarksSession - // below calls fatalHook() directly instead of triggering the real hook. @Test fun facadeForwardsToRegisteredSink() { val sink = FakeBugsnagSink() - registerBugsnagSink(sink) + CrashKiOS.configure(BugsnagCrashReporting(sink)) BugsnagKotlin.logMessage("crumb") BugsnagKotlin.setCustomValue("section1", "key1", "value1") @@ -78,31 +40,51 @@ class BugsnagSinkTest { assertEquals(2, fatalExceptions.size, "fatal should carry the cause chain") assertEquals("kotlin.IllegalStateException", (fatalExceptions[1] as NSException).name) - // Only the terminating unhandled-exception hook may mark the session — - // a direct sendFatalException() must not (it would poison the OnSendError filter). + // Only the terminating unhandled-exception hook may mark the session, + // but a direct sendFatalException() must not (it would poison the OnSendError filter). assertEquals(0, sink.fatalMarks) } @Test fun fatalHookNotifiesThenMarksSession() { - // fatalHook() is the terminating hook's actual body (see Bugsnag.kt), exercised - // directly rather than through wrapUnhandledExceptionHook — invoking the real - // hook would terminate the process. val sink = FakeBugsnagSink() - // Rebinds BugsnagKotlin.implementation to this sink (register() always - // overwrites sinkRef) so fatalHook's sendFatalException() call routes here - // rather than to whatever sink an earlier test left registered. Does NOT wire - // this sink into the real unhandled-exception hook — that binds once, to - // whichever sink registers first — which is exactly why fatalHook is called - // directly below instead of relying on the real hook firing. - registerBugsnagSink(sink) + CrashKiOS.configure(BugsnagCrashReporting(sink)) fatalHook(sink)(RuntimeException("terminating boom")) assertEquals(1, sink.fatalMarks) assertEquals(1, sink.notifications.size) - // Order matters: markFatalCrashRecorded() must run AFTER notify, never before — - // marking first would let the OnSendError filter drop a report that was never sent. + // Order matters: markFatalCrashRecorded() must run AFTER notify, never before. + // Marking first would let the OnSendError filter drop a report that was never sent. assertEquals(listOf("notify", "mark"), sink.events) } } + +private class FakeBugsnagSink : + NSObject(), + CrashKiOSBugsnagSinkProtocol { + val breadcrumbs = mutableListOf() + val notifications = mutableListOf, Boolean>>() + val metadata = mutableListOf>() + var fatalMarks = 0 + + val events = mutableListOf() + + override fun leaveBreadcrumb(message: String) { + breadcrumbs += message + } + + override fun markFatalCrashRecorded() { + fatalMarks++ + events += "mark" + } + + override fun notifyWithExceptions(exceptions: List<*>, handled: Boolean) { + notifications += exceptions to handled + events += "notify" + } + + override fun addMetadata(value: Any, key: String, section: String) { + metadata += Triple(value, key, section) + } +} diff --git a/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt b/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt index 19598ff..e336ea1 100644 --- a/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt +++ b/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt @@ -25,9 +25,10 @@ object BugsnagKotlin { /** * Call in startup code on Android. Tests should generally skip this. * - * On Apple targets this alone is NOT enough: a Swift sink must be registered via - * `registerBugsnagSink()` (which also sets the implementation, making this call - * redundant there) — otherwise events are dropped with an NSLog warning. + * On Apple targets this alone is NOT enough: a Swift sink must be configured via + * `CrashKiOS.configure(BugsnagCrashReporting(sink))` (which also sets the + * implementation, making this call redundant there) — otherwise events are dropped + * with an NSLog warning. */ fun enableBugsnag() { BugsnagKotlin.implementation = BugsnagCallsActual() diff --git a/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashKiOS.kt b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashKiOS.kt new file mode 100644 index 0000000..b150fb6 --- /dev/null +++ b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashKiOS.kt @@ -0,0 +1,27 @@ +package co.touchlab.crashkios.core + +/** + * A crash-reporting backend that knows how to wire itself into CrashKiOS. Each SDK + * module (bugsnag, crashlytics, ...) ships one implementation, constructed with its + * Swift-side sink — see `BugsnagCrashReporting`/`CrashlyticsCrashReporting`. + */ +public fun interface CrashReportingImplementation { + public fun install() +} + +/** + * Single configuration entry point for CrashKiOS. Which backend you get is a choice + * of [CrashReportingImplementation] value passed in, not a choice of which function + * you call — call this the same way regardless of backend. Kotlin `object`s bridge to + * Swift as a `shared` singleton: + * ```swift + * CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) + * ``` + * Requires `export("co.touchlab.crashkios:core")` (in addition to exporting the + * backend module) in your framework configuration for clean, unmangled Swift names. + */ +public object CrashKiOS { + public fun configure(crashReporting: CrashReportingImplementation) { + crashReporting.install() + } +} diff --git a/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt index 9af29d2..4b50bda 100644 --- a/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt +++ b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt @@ -19,8 +19,9 @@ public class CrashSinkRegistry(private val name: String) { * than crashing at startup with a clear message. */ public fun requireSink(): T = sinkRef.value ?: error( - "CrashKiOS: no $name sink registered. Call register${name}Sink() from Swift at " + - "startup (after the $name SDK is initialized) before enabling CrashKiOS from Kotlin.", + "CrashKiOS: no $name sink registered. Call CrashKiOS.configure(...) with a $name " + + "implementation from Swift at startup (after the $name SDK is initialized) " + + "before enabling CrashKiOS from Kotlin.", ) public fun register(sink: T, fatalHook: (Throwable) -> Unit) { diff --git a/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt b/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt index 7e4a18e..dc25751 100644 --- a/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt +++ b/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt @@ -39,6 +39,6 @@ class ThrowableNSExceptionTest { fun requireSinkFailsLoudWhenUnregistered() { val registry = CrashSinkRegistry("Crashlytics") val error = assertFailsWith { registry.requireSink() } - assertEquals(true, error.message?.contains("registerCrashlyticsSink")) + assertEquals(true, error.message?.contains("CrashKiOS.configure")) } } diff --git a/crashlytics/build.gradle.kts b/crashlytics/build.gradle.kts index ccc09cd..b6ebb97 100644 --- a/crashlytics/build.gradle.kts +++ b/crashlytics/build.gradle.kts @@ -55,7 +55,7 @@ kotlin { sourceSets { commonMain { dependencies { - implementation(project(":core")) + api(project(":core")) } } commonTest { diff --git a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt index 6ed0ee9..f90ff21 100644 --- a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt +++ b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt @@ -2,42 +2,45 @@ package co.touchlab.crashkios.crashlytics +import co.touchlab.crashkios.core.CrashReportingImplementation import co.touchlab.crashkios.crashlytics.objc.CrashKiOSCrashlyticsSinkProtocol import kotlinx.cinterop.ExperimentalForeignApi /** - * Registers the Swift-implemented [sink] that forwards CrashKiOS events to Firebase - * Crashlytics, and installs the unhandled-exception hook so unhandled Kotlin - * exceptions are logged as fatal crashes. + * The Crashlytics backend for [co.touchlab.crashkios.core.CrashKiOS.configure]. Wraps + * the Swift-implemented [sink] that forwards CrashKiOS events to Firebase Crashlytics. * * Call once, from Swift, at startup — after `FirebaseApp.configure()` and before any * Kotlin code runs: * ```swift * FirebaseApp.configure() - * CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) + * CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: CrashlyticsSink())) * ``` * `CrashlyticsSink` ships in the `CrashKiOSCrashlytics` Swift package (this repo's - * `Package.swift`), or copy it from the docs. Add - * `export("co.touchlab.crashkios:crashlytics")` to your framework configuration for + * `Package.swift`), or copy it from the docs. Add `export("co.touchlab.crashkios:crashlytics")` + * AND `export("co.touchlab.crashkios:core")` to your framework configuration for * clean unmangled names. * * Calling `enableCrashlytics()` (or constructing `CrashlyticsCallsActual`) on an Apple - * target WITHOUT having registered a sink fails fast with a descriptive error rather + * target WITHOUT having configured a sink fails fast with a descriptive error rather * than silently dropping crash reports. */ -public fun registerCrashlyticsSink(sink: CrashKiOSCrashlyticsSinkProtocol) { - crashlyticsRegistry.register(sink) { throwable -> - CrashlyticsKotlin.sendFatalException(throwable) +public class CrashlyticsCrashReporting(private val sink: CrashKiOSCrashlyticsSinkProtocol) : CrashReportingImplementation { + override fun install() { + crashlyticsRegistry.register(sink) { throwable -> + CrashlyticsKotlin.sendFatalException(throwable) + } + CrashlyticsKotlin.implementation = CrashlyticsCallsActual() } - CrashlyticsKotlin.implementation = CrashlyticsCallsActual() } /** - * The unhandled exception hook is now installed by [registerCrashlyticsSink]; this - * function does nothing. + * The unhandled exception hook is now installed by [CrashlyticsCrashReporting.install]; + * this function does nothing. */ @Deprecated( - "registerCrashlyticsSink() installs the unhandled-exception hook; remove this call.", + "CrashKiOS.configure(CrashlyticsCrashReporting(...)) installs the unhandled-exception " + + "hook; remove this call.", ) public fun setCrashlyticsUnhandledExceptionHook() { } diff --git a/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt b/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt index 1e1b4a1..7e63e34 100644 --- a/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt +++ b/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt @@ -2,6 +2,7 @@ package co.touchlab.crashkios.crashlytics +import co.touchlab.crashkios.core.CrashKiOS import co.touchlab.crashkios.crashlytics.objc.CrashKiOSCrashlyticsSinkProtocol import kotlin.test.Test import kotlin.test.assertEquals @@ -10,8 +11,35 @@ import kotlinx.cinterop.ExperimentalForeignApi import platform.Foundation.NSException import platform.darwin.NSObject -// Kotlin stand-in for the Swift sink: exercises the same ObjC-protocol boundary and -// proves the module links with zero linker flags (the old -U / RND-91 failure mode). +class CrashlyticsSinkTest { + @Test + fun facadeForwardsToRegisteredSink() { + val sink = FakeCrashlyticsSink() + CrashKiOS.configure(CrashlyticsCrashReporting(sink)) + + CrashlyticsKotlin.logMessage("hello") + CrashlyticsKotlin.setCustomValue("answer", "42") + CrashlyticsKotlin.setUserId("user1") + CrashlyticsKotlin.sendHandledException(RuntimeException("boom")) + CrashlyticsKotlin.sendFatalException(RuntimeException("fatal", IllegalStateException("root cause"))) + + assertEquals(listOf("hello"), sink.logs) + assertEquals("42", sink.custom["answer"]?.toString()) + assertEquals("user1", sink.userId) + + val (name, reason, addresses) = sink.handled.single() + assertEquals("kotlin.RuntimeException", name) + assertEquals("boom", reason) + assertTrue(addresses.isNotEmpty(), "handled exception should carry stack addresses") + + val fatal = sink.fatals.single() + assertEquals("kotlin.RuntimeException", fatal.name) + assertTrue(fatal.reason!!.contains("fatal")) + assertTrue(fatal.reason!!.contains("Caused by: kotlin.IllegalStateException: root cause")) + assertTrue(fatal.callStackReturnAddresses.isNotEmpty(), "fatal exception should carry stack addresses") + } +} + private class FakeCrashlyticsSink : NSObject(), CrashKiOSCrashlyticsSinkProtocol { @@ -41,32 +69,3 @@ private class FakeCrashlyticsSink : userId = identifier } } - -class CrashlyticsSinkTest { - @Test - fun facadeForwardsToRegisteredSink() { - val sink = FakeCrashlyticsSink() - registerCrashlyticsSink(sink) - - CrashlyticsKotlin.logMessage("hello") - CrashlyticsKotlin.setCustomValue("answer", "42") - CrashlyticsKotlin.setUserId("user1") - CrashlyticsKotlin.sendHandledException(RuntimeException("boom")) - CrashlyticsKotlin.sendFatalException(RuntimeException("fatal", IllegalStateException("root cause"))) - - assertEquals(listOf("hello"), sink.logs) - assertEquals("42", sink.custom["answer"]?.toString()) - assertEquals("user1", sink.userId) - - val (name, reason, addresses) = sink.handled.single() - assertEquals("kotlin.RuntimeException", name) - assertEquals("boom", reason) - assertTrue(addresses.isNotEmpty(), "handled exception should carry stack addresses") - - val fatal = sink.fatals.single() - assertEquals("kotlin.RuntimeException", fatal.name) - assertTrue(fatal.reason!!.contains("fatal")) - assertTrue(fatal.reason!!.contains("Caused by: kotlin.IllegalStateException: root cause")) - assertTrue(fatal.callStackReturnAddresses.isNotEmpty(), "fatal exception should carry stack addresses") - } -} diff --git a/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt b/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt index 56a306e..5706b54 100644 --- a/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt +++ b/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt @@ -28,10 +28,6 @@ object CrashlyticsKotlin { /** * Call in startup code on Android. Tests should generally skip this. - * - * On Apple targets this alone is NOT enough: a Swift sink must be registered via - * `registerCrashlyticsSink()` (which also sets the implementation, making this call - * redundant there) — otherwise events are dropped with an NSLog warning. */ fun enableCrashlytics() { CrashlyticsKotlin.implementation = CrashlyticsCallsActual() diff --git a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift index 7aed64a..6f3b882 100644 --- a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift +++ b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift @@ -18,10 +18,10 @@ import Bugsnag class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Configures Bugsnag for Kotlin crash handling, starts it, and registers the - // sink with the Kotlin side (which installs the unhandled-exception hook). + // Configures Bugsnag for Kotlin crash handling, starts it, and configures + // CrashKiOS with the sink (which installs the unhandled-exception hook). let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) - BugsnagKt.registerBugsnagSink(sink: sink) + CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) return true } diff --git a/samples/sample-bugsnag/shared/build.gradle.kts b/samples/sample-bugsnag/shared/build.gradle.kts index 62b2332..156cfe9 100644 --- a/samples/sample-bugsnag/shared/build.gradle.kts +++ b/samples/sample-bugsnag/shared/build.gradle.kts @@ -59,6 +59,7 @@ kotlin { homepage = "https://www.touchlab.co" framework { export("co.touchlab.crashkios:bugsnag") + export("co.touchlab.crashkios:core") isStatic = false } } diff --git a/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj b/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj index aa9c537..c17091a 100644 --- a/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj +++ b/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj @@ -9,13 +9,13 @@ /* Begin PBXBuildFile section */ 1814D0362C2C8B2A00C93985 /* CrashlyticsCallsActual.kt in Resources */ = {isa = PBXBuildFile; fileRef = 1814D0352C2C8B2A00C93985 /* CrashlyticsCallsActual.kt */; }; 1814D0382C2C95A400C93985 /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = 1814D0372C2C95A400C93985 /* FirebaseCrashlytics */; }; - AAC4A5110000000000000003 /* CrashKiOSCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = AAC4A5110000000000000002 /* CrashKiOSCrashlytics */; }; 6381FF9E277D0BE900EF27B0 /* iosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FF9D277D0BE900EF27B0 /* iosApp.swift */; }; 6381FFA0277D0BE900EF27B0 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FF9F277D0BE900EF27B0 /* ContentView.swift */; }; 6381FFA2277D0BEC00EF27B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6381FFA1277D0BEC00EF27B0 /* Assets.xcassets */; }; 6381FFA5277D0BEC00EF27B0 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6381FFA4277D0BEC00EF27B0 /* Preview Assets.xcassets */; }; 6381FFAE277D0E1200EF27B0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6381FFAC277D0E1200EF27B0 /* AppDelegate.swift */; }; A30FA4F028DB17F400E4D5A3 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A30FA4EF28DB17F400E4D5A3 /* GoogleService-Info.plist */; }; + AAC4A5110000000000000003 /* CrashKiOSCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = AAC4A5110000000000000002 /* CrashKiOSCrashlytics */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -358,7 +358,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -395,7 +395,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -439,7 +439,7 @@ /* Begin XCLocalSwiftPackageReference section */ AAC4A5110000000000000001 /* XCLocalSwiftPackageReference "../../.." */ = { isa = XCLocalSwiftPackageReference; - relativePath = "../../.."; + relativePath = ../../..; }; /* End XCLocalSwiftPackageReference section */ @@ -449,7 +449,7 @@ repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 10.28.1; + minimumVersion = 12.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift b/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift index 013a536..dc5b8b6 100644 --- a/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift +++ b/samples/sample-crashlytics/ios-spm/ios/AppDelegate.swift @@ -15,9 +15,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { FirebaseApp.configure() - // Registers the Crashlytics sink (from the CrashKiOSCrashlytics Swift package) - // and installs the Kotlin unhandled-exception hook. - CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) + // Configures CrashKiOS with the Crashlytics sink (from the CrashKiOSCrashlytics + // Swift package), which installs the Kotlin unhandled-exception hook. + CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: CrashlyticsSink())) return true } diff --git a/samples/sample-crashlytics/ios/ios/AppDelegate.swift b/samples/sample-crashlytics/ios/ios/AppDelegate.swift index 310d2e6..0f5528b 100644 --- a/samples/sample-crashlytics/ios/ios/AppDelegate.swift +++ b/samples/sample-crashlytics/ios/ios/AppDelegate.swift @@ -15,8 +15,9 @@ class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { FirebaseApp.configure() - // Registers the Crashlytics sink and installs the Kotlin unhandled-exception hook. - CrashlyticsKt.registerCrashlyticsSink(sink: CrashlyticsSink()) + // Configures CrashKiOS with the Crashlytics sink, which installs the Kotlin + // unhandled-exception hook. + CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: CrashlyticsSink())) return true } diff --git a/samples/sample-crashlytics/shared/build.gradle.kts b/samples/sample-crashlytics/shared/build.gradle.kts index b5d5aa5..bfadc41 100644 --- a/samples/sample-crashlytics/shared/build.gradle.kts +++ b/samples/sample-crashlytics/shared/build.gradle.kts @@ -50,7 +50,6 @@ kotlin { commonTest { dependencies { implementation(kotlin("test")) - } } } @@ -60,8 +59,8 @@ kotlin { homepage = "https://www.touchlab.co" ios.deploymentTarget = "14.1" framework { - // Expose registerCrashlyticsSink + the sink protocol to Swift with clean names. export("co.touchlab.crashkios:crashlytics") + export("co.touchlab.crashkios:core") isStatic = false } } diff --git a/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt b/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt deleted file mode 100644 index 6a1d3ca..0000000 --- a/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt +++ /dev/null @@ -1,16 +0,0 @@ -/* - * Copyright (c) 2021 Touchlab - * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - */ - -package co.touchlab.crashkiossample - -// CrashKiOS setup moved to Swift: the app registers a CrashlyticsSink via -// registerCrashlyticsSink() in AppDelegate (see ios/ios/AppDelegate.swift), -// which also installs the Kotlin unhandled-exception hook. -// No Kotlin-side startup call is needed anymore.