Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@ 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
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
ktlint_standard_parameter-list-wrapping = disabled
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,6 @@ fastlane/test_output

buildboth.sh
Pods
.build/
Package.resolved
.swiftpm/
56 changes: 56 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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"
),
]
)
5 changes: 3 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
105 changes: 105 additions & 0 deletions Sources/CrashKiOSBugsnag/BugsnagSink.swift
Original file line number Diff line number Diff line change
@@ -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))
}
Comment thread
ignacio-touchlab marked this conversation as resolved.
Resolved

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
}
}
1 change: 1 addition & 0 deletions Sources/CrashKiOSBugsnagObjC/CrashKiOSBugsnagObjC.m
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// We need this empty file, else SPM won't build this header-only target.
42 changes: 42 additions & 0 deletions Sources/CrashKiOSBugsnagObjC/include/CrashKiOSBugsnagSink.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#ifndef CrashKiOSBugsnagSink_h
#define CrashKiOSBugsnagSink_h

#import <Foundation/Foundation.h>

/// 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 <NSObject>

/// 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<NSException *> * _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 */
50 changes: 50 additions & 0 deletions Sources/CrashKiOSCrashlytics/CrashlyticsSink.swift
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
Copilot marked this conversation as resolved.
Resolved

public func setUserId(_ identifier: String) {
crashlytics.setUserID(identifier)
}

public func setCollectionEnabled(_ enabled: Bool) {
crashlytics.setCrashlyticsCollectionEnabled(enabled)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// We need this empty file, else SPM won't build this header-only target.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#ifndef CrashKiOSCrashlyticsSink_h
#define CrashKiOSCrashlyticsSink_h

#import <Foundation/Foundation.h>

/// 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 <NSObject>

/// 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<NSNumber *> * _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 */
30 changes: 30 additions & 0 deletions Sources/CrashKiOSCrashlyticsObjC/include/FIRCLSException.h
Original file line number Diff line number Diff line change
@@ -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 <Foundation/Foundation.h>

// 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 */
Loading
Loading