Thursday, May 21, 2026

Firebase Crashlytics and Qt 6.11.0 iOS Apps

Integrating Firebase Crashlytics into Qt 6.11.0 iOS Apps: A Complete Guide to Linker Pitfalls

Developing hybrid Qt 6.11.0 applications for iOS often requires integrating native SDKs like Firebase. While Firebase Analytics and Messaging might compile relatively easily, configuring Firebase Crashlytics alongside a static Qt build introduces severe linking conflicts.

If you've spent days fighting hundreds of duplicate symbol errors, staring at a Firebase dashboard stuck on onboarding tutorials, or arguing with support scripts, this complete guide covers the hidden pitfalls and structural workarounds required to make it work.

The Problem Space & Pitfalls

  • Stucked Crashlytics Dashboard: In debug logs you see: Completed report submission, however dashboard still shows you some tutorials information.
  • The Qt Static Linking Conflict (Real Devices): Real iOS devices link QtCore statically in Qt 6. When you apply the global Xcode -ObjC flag, the linker forcibly pulls out all object files from the static Qt archives twice. The result is over 300 duplicate symbol errors (e.g., pointing to _OBJC_CLASS_$_RunLoopModeTracker or qcore_mac.mm.o modules).
  • Firebase Support Dead End: First-line customer support operates under rigid internal scripts. Faced with a non-standard Qt/CMake build graph, they will request you to recreate the project entirely from scratch using standard Storyboards/Objective-C and suggest obsolete flags (like the macOS-only key NSApplicationCrashOnExceptions), completely ignoring valid client-side payload logs stating Completed report submission.

Step 1: Preparing the Firebase SDK & Directory Structure

To perform the integration, a manual framework SDK archive is required. Download the official Firebase.zip (Apple SDK) package from GitHub and extract it.

Organize the local assets within your project root folder exactly as follows:

your_project_root/
├── CMakeLists.txt
├── ios/
│   └── GoogleService-Info.plist
├── ios_libs/
│   └── Firebase/
│       ├── FirebaseAnalytics.xcframework
│       ├── FirebaseCrashlytics.xcframework
│       ├── FirebaseCore.xcframework
│       ├── FirebaseCoreExtension.xcframework
│       ├── FirebaseCoreInternal.xcframework
│       ├── FirebaseSessions.xcframework
│       ├── GoogleAppMeasurement.xcframework
│       ├── GoogleAppMeasurementIdentitySupport.xcframework
│       ├── GoogleUtilities.xcframework
│       ├── FBLPromises.xcframework
│       └── Promises.xcframework

In my case I leave only versions for ios-arm64 and ios-arm64_x86_64-simulator


Step 2: Fixing the Build with an Interface Target

I'm using approach where each iOS native helper is static library with it's CMakeLists.txt

To safeguard the static Qt libraries (QtCore_debug) from the destructive behavior of a global -ObjC linker parameter, I abandon top-level linker flags entirely. Instead, I encapsulate all of Firebase inside a CMake INTERFACE library target and execute a laser-targeted -force_load rule onto the binaries.

Add the following configuration block to your build script:

add_library(ios_firebase_core INTERFACE)

set(FIREBASE_LIBS_PATH "${CMAKE_SOURCE_DIR}/ios_libs/Firebase")

if(${CMAKE_OSX_SYSROOT} MATCHES "iphonesimulator")
    set(IOS_ARCH "ios-arm64_x86_64-simulator")
else()
    set(IOS_ARCH "ios-arm64")
endif()

# Direct paths to the raw static binaries inside the .xcframework trees
set(FIREBASE_FRAMEWORKS
    "${FIREBASE_LIBS_PATH}/Promises.xcframework/${IOS_ARCH}/Promises.framework/Promises"
    "${FIREBASE_LIBS_PATH}/FBLPromises.xcframework/${IOS_ARCH}/FBLPromises.framework/FBLPromises"
    "${FIREBASE_LIBS_PATH}/FirebaseCore.xcframework/${IOS_ARCH}/FirebaseCore.framework/FirebaseCore"
    "${FIREBASE_LIBS_PATH}/FirebaseCoreInternal.xcframework/${IOS_ARCH}/FirebaseCoreInternal.framework/FirebaseCoreInternal"
    "${FIREBASE_LIBS_PATH}/FirebaseCoreExtension.xcframework/${IOS_ARCH}/FirebaseCoreExtension.framework/FirebaseCoreExtension"
    "${FIREBASE_LIBS_PATH}/GoogleUtilities.xcframework/${IOS_ARCH}/GoogleUtilities.framework/GoogleUtilities"
    "${FIREBASE_LIBS_PATH}/GoogleDataTransport.xcframework/${IOS_ARCH}/GoogleDataTransport.framework/GoogleDataTransport"
    "${FIREBASE_LIBS_PATH}/nanopb.xcframework/${IOS_ARCH}/nanopb.framework/nanopb"
    "${FIREBASE_LIBS_PATH}/FirebaseInstallations.xcframework/${IOS_ARCH}/FirebaseInstallations.framework/FirebaseInstallations"
    "${FIREBASE_LIBS_PATH}/FirebaseRemoteConfigInterop.xcframework/${IOS_ARCH}/FirebaseRemoteConfigInterop.framework/FirebaseRemoteConfigInterop"
    "${FIREBASE_LIBS_PATH}/FirebaseSessions.xcframework/${IOS_ARCH}/FirebaseSessions.framework/FirebaseSessions"
    "${FIREBASE_LIBS_PATH}/FirebaseCrashlytics.xcframework/${IOS_ARCH}/FirebaseCrashlytics.framework/FirebaseCrashlytics"
    "${FIREBASE_LIBS_PATH}/FirebaseMessaging.xcframework/${IOS_ARCH}/FirebaseMessaging.framework/FirebaseMessaging"
    "${FIREBASE_LIBS_PATH}/FirebaseAnalytics.xcframework/${IOS_ARCH}/FirebaseAnalytics.framework/FirebaseAnalytics"
    "${FIREBASE_LIBS_PATH}/GoogleAppMeasurement.xcframework/${IOS_ARCH}/GoogleAppMeasurement.framework/GoogleAppMeasurement"
    "${FIREBASE_LIBS_PATH}/GoogleAppMeasurementIdentitySupport.xcframework/${IOS_ARCH}/GoogleAppMeasurementIdentitySupport.framework/GoogleAppMeasurementIdentitySupport"
)

# Explicitly pass Framework Search Paths to resolve inclusions like <FirebaseCore/...>
target_include_directories(ios_firebase_core INTERFACE
    "${FIREBASE_LIBS_PATH}/GoogleUtilities.xcframework/${IOS_ARCH}/GoogleUtilities.framework"
    "${FIREBASE_LIBS_PATH}/FirebaseMessaging.xcframework/${IOS_ARCH}/FirebaseMessaging.framework"
    "${FIREBASE_LIBS_PATH}/FirebaseAnalytics.xcframework/${IOS_ARCH}/FirebaseAnalytics.framework"
    "${FIREBASE_LIBS_PATH}/FirebaseCore.xcframework/${IOS_ARCH}/FirebaseCore.framework"
    "${FIREBASE_LIBS_PATH}/FirebaseCrashlytics.xcframework/${IOS_ARCH}/FirebaseCrashlytics.framework"
    "${FIREBASE_LIBS_PATH}/GoogleAppMeasurement.xcframework/${IOS_ARCH}/GoogleAppMeasurement.framework"
    "${FIREBASE_LIBS_PATH}/GoogleAppMeasurementIdentitySupport.xcframework/${IOS_ARCH}/GoogleAppMeasurementIdentitySupport.framework"
)

# Apply fine-grained force_load solely to the Firebase modules.
# This compiles the needed Objective-C categories without disturbing static QtCore!
foreach(FW_PATH ${FIREBASE_FRAMEWORKS})
    target_link_options(ios_firebase_core INTERFACE "LINKER:-force_load,${FW_PATH}")
endforeach()

Link the interface module back into your top-level application executable:

target_link_libraries(${PROJECT_NAME} PRIVATE ios_firebase_core)

Crucial Swift Runtime Hack: To force Xcode to correctly configure and bridge the internal Swift dependencies (Promises and Sessions) within a pure C++ target graph and prevent it from failing with system framework blocks like cannot link directly with 'SwiftUICore', create a blank file named dummy.swift in your iOS source path and include it via CMake:

target_sources(${PROJECT_NAME} PRIVATE ios/dummy.swift)

The explicit addition of a single Swift resource triggers the linker to drop pure clang++ compilation rules, moving to a balanced swiftc/clang++ hybrid configuration that correctly addresses Swift runtime search spaces.


Step 3: Native Integration & Real Test Crash Code

Inside your native Objective-C++ application scope (such as your analytical class module ios_analytics.mm), configure the Firebase state. I explicitly verify and override the data allocation parameters, ensuring the SDK does not launch in a dormant execution mode due to stale default plist parameters.

#import <FirebaseCore/FirebaseCore.h>
#import <FirebaseAnalytics/FirebaseAnalytics.h>
#import <FirebaseCrashlytics/FirebaseCrashlytics.h>

void initializeFirebase() {
    if (![FIRApp defaultApp]) {
        [FIRApp configure];
        
        [FIRAnalytics setAnalyticsCollectionEnabled:YES];
        [[FIRCrashlytics crashlytics] setCrashlyticsCollectionEnabled:YES];
        
        NSLog(@"[Firebase] Configured and Crashlytics initialized successfully.");
    }
}

void triggerTestCrash() {
    NSLog(@"--- Triggering a deliberate Firebase test crash ---");
    // Trigger an Objective-C out-of-bounds exception.
    // This creates a valid NSException instance required to clear the initial dashboard block.
    @[][1]; 
}
hr />

Step 4: Automating dSYM Upload for Xcode Archive

Firebase requires debug symbol structures (dSYMs). In standard Debug scenarios, a complex Qt application dSYM package can exceed 400 MB. Sending this payload on every local build is highly counterproductive. I optimize the symbol pipeline to execute exclusively on Release configurations and Xcode Archive workflows.

During an Archive run, Xcode automatically shifts compiled items to DerivedData/.../ArchiveIntermediates, meaning hardcoded paths in scripts will break. I implement a flexible upload_dsyms.sh shell script in the project root path:

#!/bin/bash

UPLOAD_SYMBOLS="$1"
GOOGLE_SERVICE_INFO="$2"
PROJECT_NAME="$3"

DSYM_PATH="${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}"
DSYM_BINARY="$DSYM_PATH/Contents/Resources/DWARF/$PROJECT_NAME"
echo "DSYM Path: $DSYM_PATH" 
echo "DSYM Binary: $DSYM_BINARY" 

if [ "$CONFIGURATION" != "Release" ]; then
    echo "--- Firebase: Skipping dSYM upload for $CONFIGURATION build ---"
    exit 0
fi

PLIST_PATH="${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}"

echo "--- Extracting version from: $PLIST_PATH ---"

APP_VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$PLIST_PATH" 2>/dev/null)
APP_BUILD=$(/usr/libexec/PlistBuddy -c "Print CFBundleVersion" "$PLIST_PATH" 2>/dev/null)


echo "--- Firebase: Starting dSYM upload for $CONFIGURATION and version $APP_VERSION ($APP_BUILD)---"

# Wait for dSYM to be generated — poll up to 120 seconds
MAX_WAIT=120
WAITED=0
INTERVAL=5

# while [ ! -d "$DSYM_PATH" ] || [ !"$(ls -A "$DSYM_PATH")" ]; do
while [ ! -d "$DSYM_PATH" ] || [ ! -f "$DSYM_BINARY" ] || [ ! -s "$DSYM_BINARY" ]; do
    if [ $WAITED -ge $MAX_WAIT ]; then
        echo "ERROR: Timed out waiting for dSYM at: $DSYM_PATH"
        exit 1   # exit 0 — don't fail the build over symbol upload
    fi
    echo "Waiting ($WAITED/${MAX_WAIT}s for dSYM at: $DSYM_PATH...)"
    sleep $INTERVAL
    WAITED=$((WAITED + INTERVAL))
done

echo "dSYM found at: $DSYM_PATH"

"$UPLOAD_SYMBOLS" \
    --debug \
    -gsp "$GOOGLE_SERVICE_INFO" \
    -p ios \
    "$DSYM_PATH" \
    -n "$APP_VERSION"

echo "--- Firebase: Upload finished with exit code $? ---"

Hook the script to the primary executable target via the CMake POST_BUILD step:

if(APPLE AND IOS)
    # Path to upload-symbols script
    set(FIREBASE_LIBS_PATH "${CMAKE_SOURCE_DIR}/ios_libs/Firebase")
    set(FIREBASE_UPLOAD_SYMBOLS "${FIREBASE_LIBS_PATH}/FirebaseCrashlytics.xcframework/upload-symbols")

    set(UPLOAD_SCRIPT_SRC "${CMAKE_CURRENT_SOURCE_DIR}/ios/upload_dsyms.sh")

    # NOTE: To see debug output add argument: -FIRDebugEnabled
    # XCode -> option + left click on Run button -> Run -> Arguments Passed On Launch
    add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
        COMMAND /bin/bash "${UPLOAD_SCRIPT_SRC}"
                          "${FIREBASE_UPLOAD_SYMBOLS}"
                          "${GOOGLE_SERVICE_INFO}"
                          "${PROJECT_NAME}"
        COMMENT "Firebase: Uploading dSYM symbols to Crashlytics..."
    )
endif()

The Turning Point: Excerpts from Crashlytics Support Case

During the debugging of this implementation, an extensive ticket dialogue occurred with Firebase support. The following excerpts showcase how support agents follow rigid diagnostics and often overlook custom hybrid architectures:

Firebase Support: "Upon reviewing the provided files, I noticed the following log entry: 'The default Firebase app has not yet been configured.' This suggests that the Firebase initialization code may not have been set up correctly... Additionally, after a more thorough review, it appears a step from the implementation guide was missed—specifically adding the NSApplicationCrashOnExceptions key to your Info.plist."

The Reality: The unconfigured application warning is a harmless informational trace in Qt applications that occurs if AdMob elements initialize a fraction of a millisecond prior to the main C++ class constructor—immediately followed by a clean Configuring the default app. Regarding the NSApplicationCrashOnExceptions parameter, the agent overlooked that according to their own developer manuals, this attribute is strictly macOS-only (AppKit) and entirely ignored by the iOS environment.

My Argument that broke the case: "Given that the logs explicitly state Completed report submission with id: 3e82ce246a194f048c0339e761d49332, the device has successfully packaged and transmitted the payload to the Firebase backend... Furthermore, in the very same Firebase project, the Android version of the same app is successfully capturing and displaying crashes on the dashboard. This confirms the backend project is active."

This forced the support team to supply low-level instruction to build native project and integrate Firebase. Comparing the working native trace with my hybrid application revealed the true culprit. Because of the way the compiler trees interact, Xcode's linker was completely stripping out the Objective-C categories and Swift registration sequences belonging to the FirebaseSessions runtime inside my hybrid build graph. The following critical initialization rows were completely missing from my initial logs, indicating a silent setup drop:

[FirebaseSessions] Expecting subscriptions from: [Crashlytics]
[FirebaseSessions] Registering Sessions SDK subscriber with name: Crashlytics, data collection enabled: true
[FirebaseCrashlytics] Session ID changed: c238a2277f68419c808586358be07b4c
[FirebaseSessions] Successfully logged Session Start event

Isolating the framework linkages using the CMake INTERFACE library scheme paired with individual, loop-generated force_load flags restored these missing sessions completely, bypassing the global duplication hazard.


Conclusion

The processing queues inside Firebase Crashlytics on real devices route traffic with lower processing priorities to save battery. When executing a manual test runtime (without an attached Xcode debugger active), force your exception fault, restart the application interface, and let it remain idle for roughly 15–20 seconds. It is on this subsequent initialization sequence that the cached runtime payloads clear the local disk boundaries and arrive at Google's ingestion clusters. The Event Count parameters inside your dSYM management page will remain at zero if symbols are uploaded ahead of the faults, and your tracked crash instances will cleanly reflect on your primary Issues console view.

🚗 So, now if you crash the FairMoto app, I'll know it on both Android & iOS platforms :)

No comments:

Post a Comment