diff --git a/.editorconfig b/.editorconfig index b927a29..a73313a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,15 +3,18 @@ root = true [*.{kt,kts}] end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 140 + ij_kotlin_packages_to_use_import_on_demand = true ij_kotlin_allow_trailing_comma = true ij_kotlin_allow_trailing_comma_on_call_site = true ij_kotlin_imports_layout = * ij_kotlin_indent_before_arrow_on_new_line = false ij_kotlin_line_break_after_multiline_when_entry = true -indent_size = 4 -indent_style = space -insert_final_newline = true + ktlint_argument_list_wrapping_ignore_when_parameter_count_greater_or_equal_than = 8 ktlint_chain_method_rule_force_multiline_when_chain_operator_count_greater_or_equal_than = 4 ktlint_code_style = android_studio @@ -19,5 +22,4 @@ ktlint_enum_entry_name_casing = upper_or_camel_cases ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_function_signature_body_expression_wrapping = default ktlint_ignore_back_ticked_identifier = false -max_line_length = 140 -parameter-list-wrapping = true \ No newline at end of file +ktlint_standard_parameter-list-wrapping = disabled diff --git a/.gitignore b/.gitignore index 0c4acc6..b9ed011 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,6 @@ fastlane/test_output buildboth.sh Pods +.build/ +Package.resolved +.swiftpm/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..5058e3f --- /dev/null +++ b/Package.swift @@ -0,0 +1,56 @@ +// swift-tools-version: 5.5 + +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.26.2"..<"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/RELEASING.md b/RELEASING.md index 6848e65..5c5f6e8 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,7 +1,8 @@ # Releasing -CrashKiOS publishes `co.touchlab.crashkios` artifacts (`core`, `crashlytics`, `bugsnag` and the `crashlytics-ios-link` -and `bugsnag-ios-link` Gradle plugins) to Maven Central. Releases are cut by pushing a release tag. +CrashKiOS publishes the `co.touchlab.crashkios` artifacts (`core`, `crashlytics`, `bugsnag`) to Maven Central. +Releases are cut by pushing a release tag. The Apple-side Swift sinks ship as a Swift package from this repo +(`Package.swift`) and are consumed by tag, so they need no separate publish step. ## Conventions diff --git a/Sources/CrashKiOSBugsnag/BugsnagSink.swift b/Sources/CrashKiOSBugsnag/BugsnagSink.swift new file mode 100644 index 0000000..6f2a89b --- /dev/null +++ b/Sources/CrashKiOSBugsnag/BugsnagSink.swift @@ -0,0 +1,105 @@ +import Bugsnag +import CrashKiOSBugsnagObjC +import Foundation +import ObjectiveC + +/// Feature flag that marks the Kotlin termination crash. +/// This flag is public, so custom `CrashKiOSBugsnagSink` implementations can follow the same contract. +public let kotlinCrashedFeatureFlag = "crashkios.kotlin_crashed" + +/// Prepares `config` for Kotlin crash handling. +/// +/// This function does two things: +/// - It installs an OnSendError filter. The filter suppresses the duplicate termination crash that follows a recorded Kotlin fatal. +/// - It works around Bugsnag 6.26.2+, which refuses to persist synthetic unhandled events. +/// +/// Custom sink implementations MUST call this function before `Bugsnag.start(with: config)`. +/// If they do not, Bugsnag reports every fatal Kotlin exception twice. +public func configureBugsnagForKotlin(_ config: BugsnagConfiguration) { + overrideOriginalUnhandledValue() + config.addOnSendError { event in + // Drop Bugsnag's own report of the abort that terminates the app. + // Drop it only when a Kotlin fatal is already recorded (markFatalCrashRecorded sets the flag). + !event.unhandled || !event.featureFlags.contains { $0.name == kotlinCrashedFeatureFlag } + } + config.clearFeatureFlag(name: kotlinCrashedFeatureFlag) +} + +/// Reference `CrashKiOSBugsnagSink` implementation. +/// +/// Start Bugsnag through this class. The class prepares the configuration for Kotlin +/// crash handling. Then configure CrashKiOS with the returned sink: +/// ```swift +/// let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) +/// CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) +/// ``` +/// 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 { + + /// Prepares `config` with `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 } + // `Bugsnag.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() { + // Only the Kotlin unhandled-exception hook calls this method, right before termination. + // A direct sendFatalException() call must never trigger it. That call would poison + // the session, and the OnSendError filter would discard later genuine crashes. + Bugsnag.addFeatureFlag(name: kotlinCrashedFeatureFlag) + } +} + +/// In Bugsnag 6.26.2+, the `originalUnhandledValue` property prevents Bugsnag from storing our +/// synthetic unhandled exceptions to disk. This function aliases it to `unhandled`. +/// https://github.com/bugsnag/bugsnag-cocoa/pull/1549 +private func overrideOriginalUnhandledValue() { + guard let handledStateClass = NSClassFromString("BugsnagHandledState"), + let method = class_getInstanceMethod(handledStateClass, NSSelectorFromString("unhandled")) + else { + assertionFailure("CrashKiOS: BugsnagHandledState internals changed — Kotlin fatal crashes may not be persisted. Update CrashKiOSBugsnag.") + return + } + // Bugsnag < 6.26.2 has no originalUnhandledValue. Nothing to override. + guard let originalMethod = class_getInstanceMethod(handledStateClass, NSSelectorFromString("originalUnhandledValue")) + 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/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..cf0c029 --- /dev/null +++ b/Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h @@ -0,0 +1,42 @@ +#ifndef CrashKiOSBugsnagSink_h +#define CrashKiOSBugsnagSink_h + +#import + +/// Receives crash-reporting events from Kotlin and forwards them to Bugsnag. +/// +/// Implement this protocol in Swift, in the app's build graph, where Bugsnag is already +/// linked. Pass the implementation to the Kotlin side with +/// `CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink:))`. +/// +/// Contract for implementations (the shipped `BugsnagSink` follows all of it): +/// - 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`. If it does not, Bugsnag reports +/// every fatal Kotlin exception twice: once from the Kotlin notify and once from the +/// termination abort. +@protocol CrashKiOSBugsnagSink + +/// Bugsnag `leaveBreadcrumbWithMessage:`. +- (void)leaveBreadcrumb:(NSString * _Nonnull)message; + +/// Notifies Bugsnag of a Kotlin exception. `exceptions` is `[main, cause, causeOfCause, ...]`. +/// The `callStackReturnAddresses` of each element carry its Kotlin stack trace. +/// When `handled` is NO, the implementation MUST persist the event synchronously before it +/// returns (`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; + +/// Only the Kotlin unhandled-exception hook calls this method. The hook calls it right after +/// the fatal notify, just before the process terminates. The implementation flags the +/// session (Bugsnag feature flag `crashkios.kotlin_crashed`). The OnSendError filter that +/// `configureBugsnagForKotlin(_:)` installs then drops Bugsnag's own report of the +/// termination abort. A direct `sendFatalException()` call does NOT trigger this method. +- (void)markFatalCrashRecorded; + +@end + +#endif /* CrashKiOSBugsnagSink_h */ diff --git a/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift b/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift new file mode 100644 index 0000000..669bc84 --- /dev/null +++ b/Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift @@ -0,0 +1,50 @@ +import Foundation +import CrashKiOSCrashlyticsObjC +import FirebaseCrashlytics + +/// Reference `CrashKiOSCrashlyticsSink` implementation. +/// +/// Configure CrashKiOS in your `AppDelegate`, right after you configure Firebase and before +/// any Kotlin code runs: +/// ```swift +/// FirebaseApp.configure() +/// CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: CrashlyticsSink())) +/// ``` +/// 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 + + 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) { + // This call persists synchronously. Crashlytics stores a single fatal per session, + // so it does not report the termination abort that follows a second time. + FIRCLSExceptionRecordNSException(exception) + } + + public func setCustomValue(_ value: Any?, forKey key: String) { + crashlytics.setCustomValue(value, forKey: key) + } + + public func setUserId(_ identifier: String) { + crashlytics.setUserID(identifier) + } + + public func setCollectionEnabled(_ enabled: Bool) { + crashlytics.setCrashlyticsCollectionEnabled(enabled) + } +} 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..0ec8ee7 --- /dev/null +++ b/Sources/CrashKiOSCrashlyticsObjC/include/CrashKiOSCrashlyticsSink.h @@ -0,0 +1,41 @@ +#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 handed to the Kotlin side with +/// `CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink:))`. +/// +/// 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; + +/// Crashlytics `setCrashlyticsCollectionEnabled:`. +- (void)setCollectionEnabled:(BOOL)enabled; + +@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 */ diff --git a/bugsnag-ios-link/build.gradle.kts b/bugsnag-ios-link/build.gradle.kts deleted file mode 100644 index 8969dd6..0000000 --- a/bugsnag-ios-link/build.gradle.kts +++ /dev/null @@ -1,60 +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") - 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 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 ed65287..0000000 --- a/bugsnag-ios-link/src/main/kotlin/co/touchlab/crashkios/BugsnagLinkPlugin.kt +++ /dev/null @@ -1,57 +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.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.plugin.mpp.Framework -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget - -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) - } - } -} - -private fun Project.withKotlinMultiplatformPlugin(action: Project.() -> Unit) { - pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { - action() - } -} - -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/bugsnag/build.gradle.kts b/bugsnag/build.gradle.kts index 50a1ccb..f30e483 100644 --- a/bugsnag/build.gradle.kts +++ b/bugsnag/build.gradle.kts @@ -29,6 +29,8 @@ group = GROUP version = VERSION_NAME kotlin { + explicitApi() + compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } @@ -69,7 +71,7 @@ kotlin { sourceSets { commonMain { dependencies { - implementation(project(":core")) + api(project(":core")) } } commonTest { @@ -77,12 +79,6 @@ kotlin { implementation(kotlin("test")) } } - appleMain { - dependencies { - implementation(libs.nsexceptionKt.core) - } - } - androidMain { dependencies { compileOnly(libs.bugsnag.android) @@ -93,10 +89,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/androidMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt b/bugsnag/src/androidMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt index 3bf7f7f..4baa50c 100644 --- a/bugsnag/src/androidMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt +++ b/bugsnag/src/androidMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt @@ -2,7 +2,7 @@ package co.touchlab.crashkios.bugsnag import com.bugsnag.android.Bugsnag -actual class BugsnagCallsActual : BugsnagCalls { +public actual class BugsnagCallsActual : BugsnagCalls { actual override fun logMessage(message: String) { Bugsnag.leaveBreadcrumb(message) } 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..cc6480c 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, InternalCrashKiOSApi::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.InternalCrashKiOSApi +import co.touchlab.crashkios.core.asNSException +import co.touchlab.crashkios.core.causes import kotlinx.cinterop.ExperimentalForeignApi -@OptIn(ExperimentalForeignApi::class) -actual class BugsnagCallsActual : BugsnagCalls { +internal val bugsnagRegistry = CrashSinkRegistry("Bugsnag") + +public actual class BugsnagCallsActual : BugsnagCalls { + private val sink get() = bugsnagRegistry.sink + 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): Unit = sendException(throwable, true) - actual override fun sendFatalException(throwable: Throwable) { - sendException(throwable, false) - } + actual override fun sendFatalException(throwable: Throwable): Unit = 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 sink = sink ?: return + 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/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCrashReporting.kt b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCrashReporting.kt new file mode 100644 index 0000000..2cc2bfe --- /dev/null +++ b/bugsnag/src/appleMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCrashReporting.kt @@ -0,0 +1,56 @@ +@file:OptIn(ExperimentalForeignApi::class, InternalCrashKiOSApi::class) + +package co.touchlab.crashkios.bugsnag + +import co.touchlab.crashkios.bugsnag.objc.CrashKiOSBugsnagSinkProtocol +import co.touchlab.crashkios.core.CrashReportingImplementation +import co.touchlab.crashkios.core.InternalCrashKiOSApi +import kotlinx.cinterop.ExperimentalForeignApi + +/** + * The Bugsnag implementation for [co.touchlab.crashkios.core.CrashKiOS.configure]. It wraps + * the Swift [sink] that forwards CrashKiOS events to Bugsnag. + * + * Configure CrashKiOS with this class once, from Swift, at startup. Start Bugsnag through + * the shipped sink. The sink prepares the configuration to suppress the duplicate + * termination crash: + * ```swift + * let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) + * CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) + * ``` + * `BugsnagSink` ships in the `CrashKiOSBugsnag` Swift package (this repo's `Package.swift`). + * A custom sink MUST call `configureBugsnagForKotlin(config)` from the same package + * before `Bugsnag.start`. This keeps the duplicate-crash suppression. + * + * Add `export("co.touchlab.crashkios:bugsnag")` AND `export("co.touchlab.crashkios:core")` + * to your framework configuration for clean unmangled names. + * + * You can call `enableBugsnag()` from shared startup code before or after this call. + * CrashKiOS drops events that Kotlin sends before you configure a sink. + */ +public class BugsnagCrashReporting(private val sink: CrashKiOSBugsnagSinkProtocol) : CrashReportingImplementation { + override fun install() { + bugsnagRegistry.register(sink, fatalHook { bugsnagRegistry.requireSink() }) + BugsnagKotlin.implementation = BugsnagCallsActual() + } +} + +/** + * Body of the terminating unhandled-exception hook. It reports the exception, then marks + * the session. The mark makes the sink's OnSendError filter drop Bugsnag's own report of + * the termination abort that follows. + * + * Only this hook marks the session. A direct `sendFatalException()` call must NOT mark it, + * because the caller may keep running and the filter would discard later genuine crashes. + * + * The function takes the sink as a lambda, not as a value. CrashKiOS installs the hook + * once, but `configure()` may run again with a different sink. Marking the session on a replaced + * sink would silence the wrong Bugsnag session. + * + * This is a plain function, not an inline lambda. Tests can then check the notify-then-mark + * sequence without 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/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..3e84605 --- /dev/null +++ b/bugsnag/src/appleTest/kotlin/co/touchlab/crashkios/bugsnag/BugsnagSinkTest.kt @@ -0,0 +1,102 @@ +@file:OptIn(ExperimentalForeignApi::class) + +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.assertFalse +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSException +import platform.darwin.NSObject + +class BugsnagSinkTest { + @Test + fun facadeForwardsToRegisteredSink() { + val sink = FakeBugsnagSink() + CrashKiOS.configure(BugsnagCrashReporting(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] + assertFalse(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, + // but a direct sendFatalException() must not (it would poison the OnSendError filter). + assertEquals(0, sink.fatalMarks) + } + + @Test + fun fatalHookNotifiesThenMarksSession() { + val sink = FakeBugsnagSink() + 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. + assertEquals(listOf("notify", "mark"), sink.events) + } + + @Test + fun implementationCreatedBeforeConfigureForwardsToTheLaterSink() { + val early = BugsnagCallsActual() + val sink = FakeBugsnagSink() + + CrashKiOS.configure(BugsnagCrashReporting(sink)) + early.logMessage("crumb") + + assertEquals(listOf("crumb"), sink.breadcrumbs) + } +} + +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/BugsnagCalls.kt b/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCalls.kt index 59daed1..2159e2c 100644 --- a/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCalls.kt +++ b/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCalls.kt @@ -1,16 +1,16 @@ package co.touchlab.crashkios.bugsnag -interface BugsnagCalls { - fun logMessage(message: String) +public interface BugsnagCalls { + public fun logMessage(message: String) - fun sendHandledException(throwable: Throwable) + public fun sendHandledException(throwable: Throwable) - fun sendFatalException(throwable: Throwable) + public fun sendFatalException(throwable: Throwable) - fun setCustomValue(section: String, key: String, value: Any) + public fun setCustomValue(section: String, key: String, value: Any) } -expect class BugsnagCallsActual() : BugsnagCalls { +public expect class BugsnagCallsActual() : BugsnagCalls { override fun logMessage(message: String) override fun sendHandledException(throwable: Throwable) override fun sendFatalException(throwable: Throwable) 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..38888ca 100644 --- a/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt +++ b/bugsnag/src/commonMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagKotlin.kt @@ -1,32 +1,40 @@ package co.touchlab.crashkios.bugsnag +import co.touchlab.crashkios.core.InternalCrashKiOSApi import co.touchlab.crashkios.core.ThreadSafeVar -object BugsnagKotlin { - var implementation: BugsnagCalls by ThreadSafeVar(EmptyCalls) +public object BugsnagKotlin { + @OptIn(InternalCrashKiOSApi::class) + public var implementation: BugsnagCalls by ThreadSafeVar(EmptyCalls) - fun logMessage(message: String) { + public fun logMessage(message: String) { implementation.logMessage(message) } - fun sendHandledException(throwable: Throwable) { + public fun sendHandledException(throwable: Throwable) { implementation.sendHandledException(throwable) } - fun sendFatalException(throwable: Throwable) { + public fun sendFatalException(throwable: Throwable) { implementation.sendFatalException(throwable) } - fun setCustomValue(section: String, key: String, value: Any) { + public fun setCustomValue(section: String, key: String, value: Any) { implementation.setCustomValue(section, key, value) } } /** - * 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 this function in startup code on Android. Tests should generally skip it. + * + * On Apple targets this call alone is NOT enough. Configure CrashKiOS from Swift with + * `CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: ...))`. + * That call also sets the implementation, so this function is redundant there. + * + * You can still call this function in any order, because CrashKiOS looks up the sink on + * every event. CrashKiOS drops events that Kotlin sends before `configure`. */ -fun enableBugsnag() { +public 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/jsMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt b/bugsnag/src/jsMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt index 45c438c..8456c48 100644 --- a/bugsnag/src/jsMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt +++ b/bugsnag/src/jsMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt @@ -4,7 +4,7 @@ package co.touchlab.crashkios.bugsnag * No-op implementation for targets without a Bugsnag SDK. Lets CrashKiOS be declared once in * `commonMain` in projects that also target JS. */ -actual class BugsnagCallsActual : BugsnagCalls { +public actual class BugsnagCallsActual : BugsnagCalls { actual override fun logMessage(message: String) { } diff --git a/bugsnag/src/jvmMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt b/bugsnag/src/jvmMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt index 4701359..be68ad6 100644 --- a/bugsnag/src/jvmMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt +++ b/bugsnag/src/jvmMain/kotlin/co/touchlab/crashkios/bugsnag/BugsnagCallsActual.kt @@ -4,7 +4,7 @@ package co.touchlab.crashkios.bugsnag * No-op implementation for targets without a Bugsnag SDK. Lets CrashKiOS be declared once in * `commonMain` in projects that also target JVM. */ -actual class BugsnagCallsActual : BugsnagCalls { +public actual class BugsnagCallsActual : BugsnagCalls { actual override fun logMessage(message: String) { } diff --git a/bugsnag/src/nativeInterop/cinterop/bugsnag.def b/bugsnag/src/nativeInterop/cinterop/bugsnag.def index ad438bb..2180258 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 passes +# it to CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink:)). diff --git a/build.gradle.kts b/build.gradle.kts index 9e5b3b5..1391a4e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,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/core/build.gradle.kts b/core/build.gradle.kts index d4f7634..4ed2609 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -29,6 +29,8 @@ group = GROUP version = VERSION_NAME kotlin { + explicitApi() + compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } diff --git a/core/src/androidMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt b/core/src/androidMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt index 6f7543a..52f5963 100644 --- a/core/src/androidMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt +++ b/core/src/androidMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt @@ -2,10 +2,11 @@ package co.touchlab.crashkios.core import kotlin.reflect.KProperty -actual class ThreadSafeVar actual constructor(@Volatile private var target: T) { - actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = target +@InternalCrashKiOSApi +public actual class ThreadSafeVar actual constructor(@Volatile private var target: T) { + public actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = target - actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + public actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { target = value } } 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 new file mode 100644 index 0000000..c2b4efa --- /dev/null +++ b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/CrashSinkRegistry.kt @@ -0,0 +1,36 @@ +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. + */ +@InternalCrashKiOSApi +public class CrashSinkRegistry(private val name: String) { + private val sinkRef = AtomicReference(null) + private val hookInstalled = AtomicInt(0) + + /** The registered sink, or null until `CrashKiOS.configure(...)` runs from Swift. */ + public val sink: T? get() = sinkRef.value + + /** + * Returns the registered sink, or fails loud. For paths that only run after + * [register] (such as the fatal hook), where a missing sink is a bug. + */ + public fun requireSink(): T = sink ?: error( + "CrashKiOS: no $name sink registered. Call CrashKiOS.configure(...) with a $name " + + "implementation from Swift at startup (after the $name SDK is initialized).", + ) + + 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/ThreadSafeVar.kt b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt index 8fa40f4..29c374b 100644 --- a/core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt +++ b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt @@ -3,12 +3,13 @@ package co.touchlab.crashkios.core import kotlin.concurrent.AtomicReference import kotlin.reflect.KProperty -actual class ThreadSafeVar actual constructor(target: T) { +@InternalCrashKiOSApi +public actual class ThreadSafeVar actual constructor(target: T) { private val atom = AtomicReference(target) - actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = atom.value + public actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = atom.value - actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + public actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { atom.value = value } } 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..c9d55d2 --- /dev/null +++ b/core/src/appleMain/kotlin/co/touchlab/crashkios/core/ThrowableNSException.kt @@ -0,0 +1,175 @@ +/* + * 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. + */ +@file:OptIn(InternalCrashKiOSApi::class) + +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. + */ +@InternalCrashKiOSApi +@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. + */ +@InternalCrashKiOSApi +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. + */ +@InternalCrashKiOSApi +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 + */ +@InternalCrashKiOSApi +@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) +internal 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..8072ba3 --- /dev/null +++ b/core/src/appleTest/kotlin/co/touchlab/crashkios/core/ThrowableNSExceptionTest.kt @@ -0,0 +1,63 @@ +@file:OptIn(InternalCrashKiOSApi::class) + +package co.touchlab.crashkios.core + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertSame + +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("CrashKiOS.configure")) + } + + @Test + fun sinkIsNullUntilRegisteredThenReturned() { + val registry = CrashSinkRegistry("Test") + assertNull(registry.sink) + + val first = Any() + registry.register(first) { } + assertSame(first, registry.sink) + assertSame(first, registry.requireSink()) + + val second = Any() + registry.register(second) { } + assertSame(second, registry.sink) + } +} diff --git a/core/src/commonMain/kotlin/co/touchlab/crashkios/core/InternalCrashKiOSApi.kt b/core/src/commonMain/kotlin/co/touchlab/crashkios/core/InternalCrashKiOSApi.kt new file mode 100644 index 0000000..a63a013 --- /dev/null +++ b/core/src/commonMain/kotlin/co/touchlab/crashkios/core/InternalCrashKiOSApi.kt @@ -0,0 +1,18 @@ +package co.touchlab.crashkios.core + +/** + * Marks CrashKiOS API that only the CrashKiOS SDK modules may use. There are no guarantees about the binary nor source compatibility. These + * APIs are not meant to ber used in client code. + */ +@RequiresOptIn( + message = "This is internal CrashKiOS API and is subject to change without notice.", + level = RequiresOptIn.Level.ERROR, +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, + AnnotationTarget.CONSTRUCTOR, +) +public annotation class InternalCrashKiOSApi diff --git a/core/src/commonMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt b/core/src/commonMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt index 160d58b..2c948af 100644 --- a/core/src/commonMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt +++ b/core/src/commonMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt @@ -2,8 +2,9 @@ package co.touchlab.crashkios.core import kotlin.reflect.KProperty -expect class ThreadSafeVar(target: T) { - operator fun getValue(thisRef: Any?, property: KProperty<*>): T +@InternalCrashKiOSApi +public expect class ThreadSafeVar(target: T) { + public operator fun getValue(thisRef: Any?, property: KProperty<*>): T - operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) + public operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) } diff --git a/core/src/jsMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt b/core/src/jsMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt index 82ef93a..e56a24f 100644 --- a/core/src/jsMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt +++ b/core/src/jsMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt @@ -2,10 +2,11 @@ package co.touchlab.crashkios.core import kotlin.reflect.KProperty -actual class ThreadSafeVar actual constructor(private var target: T) { - actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = target +@InternalCrashKiOSApi +public actual class ThreadSafeVar actual constructor(private var target: T) { + public actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = target - actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + public actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { target = value } } diff --git a/core/src/jvmMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt b/core/src/jvmMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt index 6f7543a..52f5963 100644 --- a/core/src/jvmMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt +++ b/core/src/jvmMain/kotlin/co/touchlab/crashkios/core/ThreadSafeVar.kt @@ -2,10 +2,11 @@ package co.touchlab.crashkios.core import kotlin.reflect.KProperty -actual class ThreadSafeVar actual constructor(@Volatile private var target: T) { - actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = target +@InternalCrashKiOSApi +public actual class ThreadSafeVar actual constructor(@Volatile private var target: T) { + public actual operator fun getValue(thisRef: Any?, property: KProperty<*>): T = target - actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + public actual operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { target = value } } diff --git a/crashlytics-ios-link/build.gradle.kts b/crashlytics-ios-link/build.gradle.kts deleted file mode 100644 index 8cb28c9..0000000 --- a/crashlytics-ios-link/build.gradle.kts +++ /dev/null @@ -1,59 +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") - 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 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 843fe72..0000000 --- a/crashlytics-ios-link/src/main/kotlin/co/touchlab/crashkios/CrashlyticsLinkPlugin.kt +++ /dev/null @@ -1,52 +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.KotlinMultiplatformExtension -import org.jetbrains.kotlin.gradle.plugin.mpp.Framework -import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget - -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) - } - } -} - -private fun Project.withKotlinMultiplatformPlugin(action: Project.() -> Unit) { - pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { - action() - } -} - -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/crashlytics/build.gradle.kts b/crashlytics/build.gradle.kts index 2173f37..57dc37e 100644 --- a/crashlytics/build.gradle.kts +++ b/crashlytics/build.gradle.kts @@ -29,6 +29,8 @@ group = GROUP version = VERSION_NAME kotlin { + explicitApi() + compilerOptions { freeCompilerArgs.add("-Xexpect-actual-classes") } @@ -69,7 +71,7 @@ kotlin { sourceSets { commonMain { dependencies { - implementation(project(":core")) + api(project(":core")) } } commonTest { @@ -77,12 +79,6 @@ kotlin { implementation(kotlin("test")) } } - appleMain { - dependencies { - implementation(libs.nsexceptionKt.core) - } - } - androidMain { dependencies { compileOnly(libs.firebase.crashlytics) @@ -92,9 +88,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/androidMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt b/crashlytics/src/androidMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt index fb5bfe6..9eab800 100644 --- a/crashlytics/src/androidMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt +++ b/crashlytics/src/androidMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt @@ -2,7 +2,7 @@ package co.touchlab.crashkios.crashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics -actual class CrashlyticsCallsActual : CrashlyticsCalls { +public actual class CrashlyticsCallsActual : CrashlyticsCalls { actual override fun logMessage(message: String) { FirebaseCrashlytics.getInstance().log(message) } diff --git a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt deleted file mode 100644 index d771bbb..0000000 --- a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/Crashlytics.kt +++ /dev/null @@ -1,13 +0,0 @@ -package co.touchlab.crashkios.crashlytics - -import com.rickclephas.kmp.nsexceptionkt.core.wrapUnhandledExceptionHook - -/** - * 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 - */ -public fun setCrashlyticsUnhandledExceptionHook(): Unit = wrapUnhandledExceptionHook { throwable -> - CrashlyticsKotlin.sendFatalException(throwable) -} 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 e4a61d4..a6e2cfd 100644 --- a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt +++ b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt @@ -1,49 +1,47 @@ +@file:OptIn(ExperimentalForeignApi::class, InternalCrashKiOSApi::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.InternalCrashKiOSApi +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() - } +public actual class CrashlyticsCallsActual : CrashlyticsCalls { + private val sink get() = crashlyticsRegistry.sink 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) } actual override fun setCollectionEnabled(enabled: Boolean) { - FIRCrashlyticsSetCollectionEnabled(enabled) + sink?.setCollectionEnabled(enabled) } } diff --git a/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCrashReporting.kt b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCrashReporting.kt new file mode 100644 index 0000000..91f2606 --- /dev/null +++ b/crashlytics/src/appleMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCrashReporting.kt @@ -0,0 +1,36 @@ +@file:OptIn(ExperimentalForeignApi::class, InternalCrashKiOSApi::class) + +package co.touchlab.crashkios.crashlytics + +import co.touchlab.crashkios.core.CrashReportingImplementation +import co.touchlab.crashkios.core.InternalCrashKiOSApi +import co.touchlab.crashkios.crashlytics.objc.CrashKiOSCrashlyticsSinkProtocol +import kotlinx.cinterop.ExperimentalForeignApi + +/** + * The Crashlytics implementation for [co.touchlab.crashkios.core.CrashKiOS.configure]. It + * wraps the Swift [sink] that forwards CrashKiOS events to Firebase Crashlytics. + * + * Configure CrashKiOS with this class once, from Swift, at startup. Do it after + * `FirebaseApp.configure()` and before any Kotlin code runs: + * ```swift + * FirebaseApp.configure() + * CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: CrashlyticsSink())) + * ``` + * `CrashlyticsSink` ships in the `CrashKiOSCrashlytics` Swift package (this repo's + * `Package.swift`). You can also 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. + * + * You can call `enableCrashlytics()` from shared startup code before or after this call. + * CrashKiOS drops events that Kotlin sends before you configure a sink. + */ +public class CrashlyticsCrashReporting(private val sink: CrashKiOSCrashlyticsSinkProtocol) : CrashReportingImplementation { + override fun install() { + crashlyticsRegistry.register(sink) { throwable -> + CrashlyticsKotlin.sendFatalException(throwable) + } + CrashlyticsKotlin.implementation = CrashlyticsCallsActual() + } +} 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..44a4c5f --- /dev/null +++ b/crashlytics/src/appleTest/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsSinkTest.kt @@ -0,0 +1,89 @@ +@file:OptIn(ExperimentalForeignApi::class) + +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 +import kotlin.test.assertTrue +import kotlinx.cinterop.ExperimentalForeignApi +import platform.Foundation.NSException +import platform.darwin.NSObject + +class CrashlyticsSinkTest { + @Test + fun facadeForwardsToRegisteredSink() { + val sink = FakeCrashlyticsSink() + CrashKiOS.configure(CrashlyticsCrashReporting(sink)) + + CrashlyticsKotlin.logMessage("hello") + CrashlyticsKotlin.setCustomValue("answer", "42") + CrashlyticsKotlin.setUserId("user1") + CrashlyticsKotlin.setCollectionEnabled(true) + 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) + assertEquals(true, sink.collectionEnabled) + + 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") + } + + @Test + fun implementationCreatedBeforeConfigureForwardsToTheLaterSink() { + val early = CrashlyticsCallsActual() + val sink = FakeCrashlyticsSink() + + CrashKiOS.configure(CrashlyticsCrashReporting(sink)) + early.logMessage("hello") + + assertEquals(listOf("hello"), sink.logs) + } +} + +private class FakeCrashlyticsSink : + NSObject(), + CrashKiOSCrashlyticsSinkProtocol { + val logs = mutableListOf() + val handled = mutableListOf>>() + val fatals = mutableListOf() + val custom = mutableMapOf() + var userId: String? = null + var collectionEnabled: Boolean? = 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 + } + + override fun setCollectionEnabled(enabled: Boolean) { + collectionEnabled = enabled + } +} diff --git a/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCalls.kt b/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCalls.kt index cd41b9c..7822739 100644 --- a/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCalls.kt +++ b/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCalls.kt @@ -1,15 +1,15 @@ package co.touchlab.crashkios.crashlytics -interface CrashlyticsCalls { - fun logMessage(message: String) - fun sendHandledException(throwable: Throwable) - fun sendFatalException(throwable: Throwable) - fun setCustomValue(key: String, value: Any) - fun setUserId(identifier: String) - fun setCollectionEnabled(enabled: Boolean) +public interface CrashlyticsCalls { + public fun logMessage(message: String) + public fun sendHandledException(throwable: Throwable) + public fun sendFatalException(throwable: Throwable) + public fun setCustomValue(key: String, value: Any) + public fun setUserId(identifier: String) + public fun setCollectionEnabled(enabled: Boolean) } -expect class CrashlyticsCallsActual() : CrashlyticsCalls { +public expect class CrashlyticsCallsActual() : CrashlyticsCalls { override fun logMessage(message: String) override fun sendHandledException(throwable: Throwable) override fun sendFatalException(throwable: Throwable) 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 d69d71e..ddfee93 100644 --- a/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt +++ b/crashlytics/src/commonMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsKotlin.kt @@ -1,40 +1,48 @@ package co.touchlab.crashkios.crashlytics +import co.touchlab.crashkios.core.InternalCrashKiOSApi import co.touchlab.crashkios.core.ThreadSafeVar -object CrashlyticsKotlin { - var implementation: CrashlyticsCalls by ThreadSafeVar(EmptyCalls) +public object CrashlyticsKotlin { + @OptIn(InternalCrashKiOSApi::class) + public var implementation: CrashlyticsCalls by ThreadSafeVar(EmptyCalls) - fun logMessage(message: String) { + public fun logMessage(message: String) { implementation.logMessage(message) } - fun sendHandledException(throwable: Throwable) { + public fun sendHandledException(throwable: Throwable) { implementation.sendHandledException(throwable) } - fun sendFatalException(throwable: Throwable) { + public fun sendFatalException(throwable: Throwable) { implementation.sendFatalException(throwable) } - fun setCustomValue(key: String, value: Any) { + public fun setCustomValue(key: String, value: Any) { implementation.setCustomValue(key, value) } - fun setUserId(identifier: String) { + public fun setUserId(identifier: String) { implementation.setUserId(identifier) } - fun setCollectionEnabled(enabled: Boolean) { + public fun setCollectionEnabled(enabled: Boolean) { implementation.setCollectionEnabled(enabled) } } /** - * 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 this function in startup code on Android. Tests should generally skip it. + * + * On Apple targets this call alone is NOT enough. Configure CrashKiOS from Swift with + * `CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink: ...))`. + * That call also sets the implementation, so this function is redundant there. + * + * You can still call this function in any order, because CrashKiOS looks up the sink on + * every event. CrashKiOS drops events that Kotlin sends before `configure`. */ -fun enableCrashlytics() { +public fun enableCrashlytics() { CrashlyticsKotlin.implementation = CrashlyticsCallsActual() } diff --git a/crashlytics/src/jsMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt b/crashlytics/src/jsMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt index 7292423..cfcd544 100644 --- a/crashlytics/src/jsMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt +++ b/crashlytics/src/jsMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt @@ -4,7 +4,7 @@ package co.touchlab.crashkios.crashlytics * No-op implementation for targets without a Crashlytics SDK. Lets CrashKiOS be declared once in * `commonMain` in projects that also target JS. */ -actual class CrashlyticsCallsActual : CrashlyticsCalls { +public actual class CrashlyticsCallsActual : CrashlyticsCalls { actual override fun logMessage(message: String) { } diff --git a/crashlytics/src/jvmMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt b/crashlytics/src/jvmMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt index f5be6e3..37a4b1e 100644 --- a/crashlytics/src/jvmMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt +++ b/crashlytics/src/jvmMain/kotlin/co/touchlab/crashkios/crashlytics/CrashlyticsCallsActual.kt @@ -4,7 +4,7 @@ package co.touchlab.crashkios.crashlytics * No-op implementation for targets without a Crashlytics SDK. Lets CrashKiOS be declared once in * `commonMain` in projects that also target JVM. */ -actual class CrashlyticsCallsActual : CrashlyticsCalls { +public actual class CrashlyticsCallsActual : CrashlyticsCalls { actual override fun logMessage(message: String) { } diff --git a/crashlytics/src/nativeInterop/cinterop/crashlytics.def b/crashlytics/src/nativeInterop/cinterop/crashlytics.def index 309b9ee..fb61af8 100644 --- a/crashlytics/src/nativeInterop/cinterop/crashlytics.def +++ b/crashlytics/src/nativeInterop/cinterop/crashlytics.def @@ -1,160 +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 FIRCrashlyticsSetCollectionEnabled(BOOL enabled) { - id crashlytics = FIRCrashlyticsInstance(); - SEL selector = NSSelectorFromString(@"setCrashlyticsCollectionEnabled:"); - void (*setCrashlyticsCollectionEnabled)(id, SEL, BOOL) = FIRMethodForSelector(crashlytics, selector); - setCrashlyticsCollectionEnabled(crashlytics, selector, enabled); -} - -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 passes +# it to CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(sink:)). diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b2ec449..2d0da0a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,11 +9,8 @@ kotlin = "2.2.21" android-gradle-plugin = "8.11.1" mavenPublish = "0.35.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/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..4a75832 100644 --- a/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift +++ b/samples/sample-bugsnag/CrashKiOSSampleIOS/CrashKiOSSampleIOS/AppDelegate.swift @@ -13,14 +13,17 @@ import UIKit import shared import Bugsnag +// class_getInstanceMethod / method_setImplementation, used by overrideOriginalUnhandledValue(). +import ObjectiveC @UIApplicationMain 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 configures + // CrashKiOS with the sink (which installs the unhandled-exception hook). + let sink = BugsnagSink.start(BugsnagConfiguration.loadConfig()) + CrashKiOS.shared.configure(crashReporting: BugsnagCrashReporting(sink: sink)) return true } @@ -40,3 +43,80 @@ 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 method = class_getInstanceMethod(handledStateClass, NSSelectorFromString("unhandled")) + else { + assertionFailure("CrashKiOS: BugsnagHandledState internals changed — Kotlin fatal crashes may not be persisted. Update CrashKiOSBugsnag.") + return + } + // Bugsnag < 6.26.2 has no originalUnhandledValue. Nothing to override. + guard let originalMethod = class_getInstanceMethod(handledStateClass, NSSelectorFromString("originalUnhandledValue")) + 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 b413873..4f5eb9d 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.14.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/sample-bugsnag/settings.gradle.kts b/samples/sample-bugsnag/settings.gradle.kts index 5733f28..f953b7c 100644 --- a/samples/sample-bugsnag/settings.gradle.kts +++ b/samples/sample-bugsnag/settings.gradle.kts @@ -22,8 +22,8 @@ includeBuild("../..") { dependencySubstitution { substitute(module("co.touchlab.crashkios:bugsnag")) .using(project(":bugsnag")).because("we want to auto-wire up sample dependency") - substitute(module("co.touchlab.crashkios.bugsnaglink:co.touchlab.crashkios.bugsnaglink.gradle.plugin")) - .using(project(":bugsnag-ios-link")).because("we want to auto-wire up sample dependency") + substitute(module("co.touchlab.crashkios:core")) + .using(project(":core")).because("we want to auto-wire up sample dependency") } } diff --git a/samples/sample-bugsnag/shared/build.gradle.kts b/samples/sample-bugsnag/shared/build.gradle.kts index 200fadd..07bd97e 100644 --- a/samples/sample-bugsnag/shared/build.gradle.kts +++ b/samples/sample-bugsnag/shared/build.gradle.kts @@ -14,7 +14,6 @@ plugins { id("com.android.library") kotlin("multiplatform") kotlin("native.cocoapods") - id("co.touchlab.crashkios.bugsnaglink") } android { @@ -57,6 +56,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/build.gradle.kts b/samples/sample-crashlytics/build.gradle.kts index 0a95540..f1dcf9c 100644 --- a/samples/sample-crashlytics/build.gradle.kts +++ b/samples/sample-crashlytics/build.gradle.kts @@ -20,7 +20,6 @@ buildscript { 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/ios-spm/ios.xcodeproj/project.pbxproj b/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj index 7dca028..be19b77 100644 --- a/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj +++ b/samples/sample-crashlytics/ios-spm/ios.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 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 */ @@ -35,6 +36,7 @@ buildActionMask = 2147483647; files = ( 1814D0382C2C95A400C93985 /* FirebaseCrashlytics in Frameworks */, + AAC4A5110000000000000003 /* CrashKiOSCrashlytics in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -109,6 +111,7 @@ name = ios; packageProductDependencies = ( 1814D0372C2C95A400C93985 /* FirebaseCrashlytics */, + AAC4A5110000000000000002 /* CrashKiOSCrashlytics */, ); productName = ios; productReference = 6381FF9A277D0BE900EF27B0 /* ios.app */; @@ -140,6 +143,7 @@ mainGroup = 6381FF91277D0BE900EF27B0; packageReferences = ( 1814D0312C2C5B6D00C93985 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + AAC4A5110000000000000001 /* XCLocalSwiftPackageReference "../../.." */, ); productRefGroup = 6381FF9B277D0BE900EF27B0 /* Products */; projectDirPath = ""; @@ -376,7 +380,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", @@ -413,7 +417,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", @@ -454,13 +458,20 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + AAC4A5110000000000000001 /* XCLocalSwiftPackageReference "../../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../../..; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ 1814D0312C2C5B6D00C93985 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 10.28.1; + minimumVersion = 12.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ @@ -471,6 +482,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..dc5b8b6 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() + // 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.xcodeproj/project.pbxproj b/samples/sample-crashlytics/ios/ios.xcodeproj/project.pbxproj index 6e1ff57..546f5e6 100644 --- a/samples/sample-crashlytics/ios/ios.xcodeproj/project.pbxproj +++ b/samples/sample-crashlytics/ios/ios.xcodeproj/project.pbxproj @@ -407,6 +407,7 @@ PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.crashkios.sample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ios/BridgingHeader.h; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -439,6 +440,7 @@ PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.crashkios.sample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OBJC_BRIDGING_HEADER = ios/BridgingHeader.h; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; diff --git a/samples/sample-crashlytics/ios/ios/AppDelegate.swift b/samples/sample-crashlytics/ios/ios/AppDelegate.swift index 586ba34..7d039df 100644 --- a/samples/sample-crashlytics/ios/ios/AppDelegate.swift +++ b/samples/sample-crashlytics/ios/ios/AppDelegate.swift @@ -9,14 +9,55 @@ 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() + // Configures CrashKiOS with the Crashlytics sink, which installs the Kotlin + // unhandled-exception hook. + CrashKiOS.shared.configure(crashReporting: CrashlyticsCrashReporting(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, forKey: key) + } + + func setUserId(_ identifier: String) { + Crashlytics.crashlytics().setUserID(identifier) + } + + func setCollectionEnabled(_ enabled: Bool) { + Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(enabled) + } +} + +// 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..bc28259 --- /dev/null +++ b/samples/sample-crashlytics/ios/ios/BridgingHeader.h @@ -0,0 +1,22 @@ +#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; +- (void)setCollectionEnabled:(BOOL)enabled; +@end + +#endif /* BridgingHeader_h */ diff --git a/samples/sample-crashlytics/settings.gradle.kts b/samples/sample-crashlytics/settings.gradle.kts index 98a4d1f..0b97383 100644 --- a/samples/sample-crashlytics/settings.gradle.kts +++ b/samples/sample-crashlytics/settings.gradle.kts @@ -22,8 +22,8 @@ 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") + substitute(module("co.touchlab.crashkios:core")) + .using(project(":core")).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 04a3b95..1e5ee9d 100644 --- a/samples/sample-crashlytics/shared/build.gradle.kts +++ b/samples/sample-crashlytics/shared/build.gradle.kts @@ -14,7 +14,6 @@ plugins { id("com.android.library") kotlin("multiplatform") kotlin("native.cocoapods") - id("co.touchlab.crashkios.crashlyticslink") } android { @@ -57,6 +56,8 @@ kotlin { homepage = "https://www.touchlab.co" ios.deploymentTarget = "14.1" framework { + 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 3d7a330..0000000 --- a/samples/sample-crashlytics/shared/src/iosMain/kotlin/co/touchlab/crashkiossample/Helper.kt +++ /dev/null @@ -1,20 +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 - -import co.touchlab.crashkios.crashlytics.enableCrashlytics -import co.touchlab.crashkios.crashlytics.setCrashlyticsUnhandledExceptionHook - -@Suppress("unused") -fun startCrashKiOS(){ - enableCrashlytics() - setCrashlyticsUnhandledExceptionHook() -} 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 {