firebase-kotlin-sdk icon indicating copy to clipboard operation
firebase-kotlin-sdk copied to clipboard

ios test task is failing with: "Id: framework not found FirebaseCore"

Open Link184 opened this issue 1 year ago • 17 comments
trafficstars

Steps to reproduce:

  1. add dev.gitlive:firebase-firestore:1.12.0 to common dependencies
  2. optional step to fix normal ios builds: add iosSimilatorArm64().binaries.framework { isStatic = true }
  3. make sure you have at least 1 test defined in commonTest source set
  4. run :myKmpModule:allTests

Notes: The build is failing only on ios test task, another platform tests are running fine. Looks like isStatic and kotlin.mpp.enableCInteropCommonization flags are not not working on test builds

Link184 avatar Apr 28 '24 20:04 Link184

In your project add:

cocoapods {
   pod("FirebaseCore") { version = "10.19.0" }
}

Unfortunately cocoapod dependencies are not transitive so your tests wont be able to find them

Daeda88 avatar May 03 '24 08:05 Daeda88

@Daeda88 How achieve it with SPM ?

ghost avatar Jun 06 '24 11:06 ghost

@PRUJA you cannot achieve this with SPM since Kotlin Native has no support for it. The problem with this is not in your iOS project, but rather in the app that gradle builds internally to run tests on. It needs to have the frameworks linked to start, much like your real iOS project. If the cocoapods block is too hefty for you (which I can imagine as it increases build times by a lot) you can manually set it in the block IFF you know the location of your frameworks:

val iosTarget: KotlinNativeTarget.() -> Unit = {
    binaries {
        getTest("DEBUG").apply {
              linkerOpts() // Set link to Framework here
        }
   }
}
iosX64(iosTarget)
iosArm64(iosTarget)
iosSimulatorArm64(iosTarget)

Daeda88 avatar Jun 06 '24 12:06 Daeda88

In our own project we have a separate repo that simply contains the cocoapods block in gradle, and then use it to retrieve the required LinkerOpts for our project:

https://pl.kotl.in/LaYzHO_uX

Use it as:

val podsToAdd = listOf(// add names of pods to link here)
binaries {
    getTest("DEBUG").apply {
        linkFrameworkSearchPaths(cachedCocoapodsPath) {
            it in podsToAdd
        }
    }
}

Daeda88 avatar Jun 06 '24 12:06 Daeda88

Just to clarify, @Daeda88, both of the solutions you suggest above require the iOS project to have Cocoapods set up, right? I removed them a while ago in favor of an all-SPM setup so I'd really like to avoid that if possible.

And I'm getting this linking error for iOS app builds (no tests) even though my iOS app has FirebaseCore included as its frameworks.

If I don't care about tests for now, is there a simpler fix or will it need to be the same as above?

Is there a long-term fix that could remove the need for these special build setups?

Thanks!

treitter avatar Dec 02 '24 21:12 treitter

You can use SPM just fine for your ios project. I have it linked like that as well. The problem is really just the Kotlin side.

Daeda88 avatar Dec 04 '24 06:12 Daeda88

@Daeda88 does that mean it should be possible to apply a simplified version of your solution without setting up Cocoapods? Eg, something like this? If you could correct my syntax, I'd really appreciate it:

binaries {
    getTest("DEBUG").apply {
        linkerOpts = mutableListOf("-F<path/to/frameworks> -framework FirebaseCore")
    }
}

Which file(s) would I be looking for to determine the path for the -F<path> flag? Eg, I see these:

<project root>/build/ios/Debug-iphonesimulator/Firebase_FirebaseCore.bundle
<project root>/build/ios/Debug-iphonesimulator/FirebaseCore.o

among others.

Do I only have to set up the getTest() portion for the debug build or do I also have to do it for the release build? Is this test program just a throwaway in the sense that I could just add the FirebaseCore.o to the linkerOpts directly and have it be statically-linked for the test program but not otherwise affect the built app?

Or is there no way around sourcing via cocoapods? If that's the only option, do I set up cocoapods with an empty Podfile then just add in the shared build.gradle.kts like above?:

cocoapods {
   pod("FirebaseCore") { version = "10.19.0" }
}

or do I have to also set that in the Podfile?

Thanks again!

treitter avatar Dec 04 '24 08:12 treitter

Yeah, you should be able to set it up like that in some what. Kotlin just marks the project as having a dependency on the Firebase frameworks. If you can set its path throug the linkeropts it would work without using the cocapods plugin.

Do I only have to set up the getTest() portion for the debug build or do I also have to do it for the release build? Is this test program just a throwaway in the sense that I could just add the FirebaseCore.o to the linkerOpts directly and have it be statically-linked for the test program but not otherwise affect the built app?

Anything in the binaries.getTest block will not be linked to your real project.

Daeda88 avatar Dec 04 '24 13:12 Daeda88

@Daeda88 does that mean it should be possible to apply a simplified version of your solution without setting up Cocoapods? Eg, something like this? If you could correct my syntax, I'd really appreciate it:

binaries {
    getTest("DEBUG").apply {
        linkerOpts = mutableListOf("-F<path/to/frameworks> -framework FirebaseCore")
    }
}

Which file(s) would I be looking for to determine the path for the -F<path> flag? Eg, I see these:

<project root>/build/ios/Debug-iphonesimulator/Firebase_FirebaseCore.bundle
<project root>/build/ios/Debug-iphonesimulator/FirebaseCore.o

among others.

Do I only have to set up the getTest() portion for the debug build or do I also have to do it for the release build? Is this test program just a throwaway in the sense that I could just add the FirebaseCore.o to the linkerOpts directly and have it be statically-linked for the test program but not otherwise affect the built app?

Or is there no way around sourcing via cocoapods? If that's the only option, do I set up cocoapods with an empty Podfile then just add in the shared build.gradle.kts like above?:

cocoapods {
   pod("FirebaseCore") { version = "10.19.0" }
}

or do I have to also set that in the Podfile?

Thanks again!

@Daeda88 Hello! Did you find a solution for this? I believe I am facing the exact same issue. Thank you for your help.

davidejones88 avatar Jan 07 '25 01:01 davidejones88

@Daeda88 Hello! Did you find a solution for this? I believe I am facing the exact same issue. Thank you for your help.

I had to put this on the back burner for a while and now that I've returned to it, I'm still hitting the same problem.

@Daeda88 would you mind sharing the -F, -framework and -rpath flags that your project currently adds by the linkFrameworkSearchPaths() you mention above (ie, the strings passed to linkerOpts() in a current build of your project)? I think that would really help me figure out what I need to add to my project.

Thank you!

treitter avatar Mar 06 '25 19:03 treitter

Also, it doesn't appear that, including firebase-ios-sdk in my iOS build via SPM, that the appropriate DerivedData directory even contains a .framework for FirebaseCore. All I see are FirebaseAnalytics.framework, FirebaseFirestoreInternal.framework as far as .framework; a number of .swiftmodule (eg, FirebaseAuth.swiftmodule, FirebaseCoreInternal.swiftmodule), a number of .bundle (eg, Firebase_FirebaseAuth.bundle, Firebase_FirebaseCore.bundle), and a large number of Mach-O object .o files (eg, Firebase.o, FirebaseAuth.o, FirebaseCore.o).

Based on the files present, is it even possible to link properly? I even tried including FirebaseCore.o as a bare linkerOpt() but, not surprisingly, that didn't work. I'm grasping at straws here.

Any suggestion would be very helpful. Thanks!

treitter avatar Mar 07 '25 06:03 treitter

I have this code for my buildSrc:

package health.splendo.gradle.utils

import org.gradle.api.file.Directory
import org.jetbrains.kotlin.gradle.plugin.mpp.AbstractExecutable
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeCompilation
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
import org.jetbrains.kotlin.gradle.plugin.mpp.NativeBinary
import org.jetbrains.kotlin.konan.file.File
import org.jetbrains.kotlin.konan.properties.Properties
import org.jetbrains.kotlin.konan.properties.hasProperty
import org.jetbrains.kotlin.konan.properties.loadProperties

private val rootPath = "build/cocoapods"
private val splitRegex = "(\" )?\"".toRegex()

private val KotlinNativeTarget.targetType: String get() = when (konanTarget) {
    is org.jetbrains.kotlin.konan.target.KonanTarget.IOS_X64,
    is org.jetbrains.kotlin.konan.target.KonanTarget.IOS_SIMULATOR_ARM64,
    -> "iosSimulator"
    else -> "ios"
}

private fun Properties.getPropertyOrNull(key: String) = if (hasProperty(key)) getProperty(key) else null
private fun Properties.getListProperty(key: String) = getPropertyOrNull(key).orEmpty().split(
    splitRegex,
).filter { it.isNotEmpty() }

private fun Properties.getFrameworkSearchPaths() = (getListProperty("FRAMEWORK_SEARCH_PATHS") + getPropertyOrNull("CONFIGURATION_BUILD_DIR")).filterNotNull()

/**
 * Gets the [Properties] file of a [KotlinNativeTarget] for a given dependency loaded by the `dependencies` module
 * @param dependency the name of the dependency to get the properties from
 * @param pathToIosDependencies the absolute path to the root folder of the health-ios-dependencies module
 * @return the [Properties] file of the dependency
 */
private fun KotlinNativeTarget.getPropertiesForDependency(dependency: String, pathToIosDependencies: Directory): Properties {
    val file = pathToIosDependencies.file("$rootPath/buildSettings/build-settings-$targetType-$dependency.properties").asFile
    return File(file.absolutePath).loadProperties()
}

/**
 * Load a list of all dependencies loaded by the `dependencies` module are added to a `NativeBinary`
 * This checks the `defs` folder to determine these dependencies
 * @param pathToIosDependencies the absolute path to the root folder of the health-ios-dependencies module
 * @param includeDependency returns whether a dependency with a given name should be returned
 * @return the list of dependencies to be added
 */
private fun dependenciesFromDefs(pathToIosDependencies: Directory, includeDependency: (String) -> Boolean = { true }): List<String> {
    // Load all .def files from the defs folder to determine the list of dependencies
    val defs = File(pathToIosDependencies.file("$rootPath/defs").asFile.absolutePath)
    return defs.listFilesOrEmpty.mapNotNull { dependency ->
        val fileName = dependency.name.removeSuffix(".${dependency.extension}")
        when {
            dependency.extension != "def" -> null
            includeDependency(fileName) -> fileName
            else -> null
        }
    }
}

/**
 * Creates the framework search paths required to add all dependencies loaded by the `dependencies` module
 * @param pathToIosDependencies the absolute path to the root folder of the health-ios-dependencies module
 * @param includeDependency returns whether a dependency with a given name should be returned
 * @return the set of paths to add to the framework search paths
 */
private fun KotlinNativeTarget.createFrameworkSearchPath(pathToIosDependencies: Directory, includeDependency: (String) -> Boolean = { false }): Set<String> {
    val dependencies = dependenciesFromDefs(pathToIosDependencies, includeDependency)
    return dependencies.map { dependency ->
        val properties = getPropertiesForDependency(dependency, pathToIosDependencies)
        properties.getFrameworkSearchPaths()
    }.flatten().toSet()
}

/**
 * Ensures that all dependencies loaded by the `dependencies` module are added to a `NativeBinary`
 * @param pathToIosDependencies the absolute path to the root folder of the health-ios-dependencies module
 * @param includeDependency returns whether a dependency with a given name should be added to the `NativeBinary`.
 */
fun NativeBinary.linkFrameworkSearchPaths(pathToIosDependencies: Directory, includeDependency: (String) -> Boolean = { false }) {
    val frameworkSearchPaths =
        target.createFrameworkSearchPath(pathToIosDependencies, includeDependency)
            .map { path ->
                path.mapArchivedPath(pathToIosDependencies).asFile.path
            }

    // Add all framework search paths
    linkerOpts(frameworkSearchPaths.map { "-F$it" })

    // Add all frameworks specified by the framework search paths
    dependenciesFromDefs(pathToIosDependencies, includeDependency).forEach { dependency ->
        val frameworkFileExists = frameworkSearchPaths.any { dir -> File("$dir/$dependency.framework").exists }
        if (frameworkFileExists) linkerOpts("-framework", dependency)
    }
    // For executable we should set the rpath so the framework is included in the build
    if (this is AbstractExecutable) {
        frameworkSearchPaths.forEach {
            linkerOpts("-rpath", it)
        }
    }
}

/**
 * Adds a dependency for a given name to a project
 * This assumes the dependency has been loaded by the `dependencies` module
 * @param dependency the name of the Dependency to add
 * @param pathToIosDependencies the absolute path to the root folder of the health-ios-dependencies module
 */
fun KotlinNativeCompilation.addDependency(dependency: String, pathToIosDependencies: Directory) {
    cinterops.create(dependency) {
        definitionFile.set(pathToIosDependencies.file("$rootPath/defs/$dependency.def").asFile)
        packageName = "cocoapods.$dependency"
        val properties = target.getPropertiesForDependency(dependency, pathToIosDependencies)
        val headerSearchPaths = properties.getListProperty("HEADER_SEARCH_PATHS").map { path -> path.mapArchivedPath(pathToIosDependencies) }
        val publicHeadersFolderPath = properties.getPropertyOrNull("PUBLIC_HEADERS_FOLDER_PATH")?.let {
            pathToIosDependencies.file("$rootPath/synthetic/IOS/build/Release-${target.targetType}/$it").asFile.absolutePath
        }
        compilerOpts.addAll((headerSearchPaths + publicHeadersFolderPath).filterNotNull().map { "-I$it" })
        compilerOpts.addAll(properties.getFrameworkSearchPaths().map { path -> "-F${path.mapArchivedPath(pathToIosDependencies)}" })
    }
}

/**
 * map a potentially archived build (which will contain absolute links) to a local path
 */
private fun String.mapArchivedPath(pathToIosDependencies: Directory) = if (indexOf(rootPath) != -1) {
    pathToIosDependencies.dir(substring(indexOf(rootPath)))
} else {
    pathToIosDependencies
}

Then, in my iosTarget gradle:

binaries {
    framework {
         getTest("DEBUG").apply {
                linkFrameworkSearchPaths(cachedCocoapodsPath) {
                    true
                }
         }
    }
}

Where cachedCocoapodsPath is the path to a gradle project that has

kotlin {
    cocoapods {
        // Pods required
    }
}

This allows me to separately load the cocoapods cache without gradle redownloading it every time I sync the project.

Daeda88 avatar Mar 07 '25 12:03 Daeda88

I have this code for my buildSrc: ...

@Daeda88 thanks, I'm giving this a try!

treitter avatar Mar 11 '25 05:03 treitter

Looks like I'm getting close - currently failing here when running commonTest for simulator arm64:

in KotlinNativeTarget.getPropertiesForDependency()

this line: return File(file.absolutePath).loadProperties()

because this file doesn't exist:

/path/to/FirebaseCocoapodsApp/shared/build/cocoapods/buildSettings/build-settings-ios-FirebaseCore.properties

however, I do have build-settings-iphoneos-FirebaseCore.properties and build-settings-iphonesimulator-FirebaseCore.properties in the same directory.

I assumed I needed to just build FirebaseCocoapodsApp as generic iOS ("Any iOS Device (arm64)"). That succeeds but apparently doesn't generate the properties file for ios. Shouldn't it be checking simulator instead?

@Daeda88 do you know what I'm missing? Thanks!

treitter avatar Mar 12 '25 16:03 treitter

What version of Kotlin are you targeting. Iirc, the properties files name changed as of k2.1, my shared code reflects that. Just change the filename in code to the correct path for the architecture or upgrade to k2.1

Daeda88 avatar Mar 12 '25 17:03 Daeda88

Ah, that did it! Thank you! My FirebaseCocoapodsApp had been targeting Kotlin 2.0 so the upgrade to 2.1.0 got everything aligned.

Longer-term, what would be the best option so we can avoid?:

  • needing to either use Cocoapods directly or
  • having a separate app that includes them that our main app depends upon and including these extra Gradle functions

Ie, what could be done in firebase-kotlin-sdk or upstream in Kotlin/1st-party Kotlin projects and/or firebase-ios-sdk so the setup and maintenance is a bit easier for projects depending upon firebase-kotlin-sdk? Is it feasible or a good idea for the test executable in commonTest to get its Firebase dependencies transitively through SPM?

I'd be happy to help out where I can.

Thanks again!

treitter avatar Mar 13 '25 16:03 treitter

Id wait for whatever Jetbrains announces at Kotlinconf. They're working on making things easier for libraries, so I imagine this will get easier when they make it so.

Daeda88 avatar Mar 17 '25 13:03 Daeda88