diff --git a/android/build.gradle b/android/build.gradle index 1659cd5..3c730df 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -81,6 +81,8 @@ dependencies { implementation 'io.reactivex.rxjava3:rxkotlin:3.0.1' implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.frontegg.sdk:android:1.3.18' + + testImplementation 'junit:junit:4.13.2' } if (isNewArchitectureEnabled()) { diff --git a/android/src/main/java/com/frontegg/reactnative/Utils.kt b/android/src/main/java/com/frontegg/reactnative/Utils.kt index 6a7357b..cbe21b5 100644 --- a/android/src/main/java/com/frontegg/reactnative/Utils.kt +++ b/android/src/main/java/com/frontegg/reactnative/Utils.kt @@ -2,11 +2,14 @@ package com.frontegg.reactnative import android.app.Activity import android.content.Context +import android.content.pm.PackageManager import android.util.Log const val TAG: String = "FronteggUtils" +private const val BUILD_CONFIG_PACKAGE_META = "com.frontegg.reactnative.BUILD_CONFIG_PACKAGE" + interface ActivityProvider { fun getActivity(): Activity? @@ -14,38 +17,99 @@ interface ActivityProvider { val Context.fronteggConstants: FronteggConstants get() { - val packageName = this.packageName - val className = "$packageName.BuildConfig" + val buildConfigClass = resolveBuildConfigClass(this) + + // Get the field from BuildConfig class + val baseUrl = safeGetValueFromBuildConfig(buildConfigClass, "FRONTEGG_DOMAIN", "") + val clientId = safeGetValueFromBuildConfig(buildConfigClass, "FRONTEGG_CLIENT_ID", "") + + val applicationId = + safeGetNullableValueFromBuildConfig(buildConfigClass, "FRONTEGG_APPLICATION_ID", "") + + val useAssetsLinks = + safeGetValueFromBuildConfig(buildConfigClass, "FRONTEGG_USE_ASSETS_LINKS", true) + val useChromeCustomTabs = safeGetValueFromBuildConfig( + buildConfigClass, "FRONTEGG_USE_CHROME_CUSTOM_TABS", true + ) + + return FronteggConstants( + baseUrl = baseUrl, + clientId = clientId, + applicationId = applicationId, + useAssetsLinks = useAssetsLinks, + useChromeCustomTabs = useChromeCustomTabs, + bundleId = this.packageName, + ) + } + +/** + * Locate the host app's generated `BuildConfig` class. + * + * The class lives at `.BuildConfig`, but apps whose `applicationId` differs from + * their AGP `namespace` (a common pattern for white-label / multi-flavor builds) need to + * override the lookup, since [Context.getPackageName] returns the `applicationId` at runtime + * while the generated `BuildConfig` is emitted under the `namespace`. + * + * Resolution order: + * 1. `.BuildConfig` (the existing behaviour). + * 2. The value of the `com.frontegg.reactnative.BUILD_CONFIG_PACKAGE` `` entry on + * `` in `AndroidManifest.xml`, suffixed with `.BuildConfig`. + */ +private fun resolveBuildConfigClass(context: Context): Class<*> { + val candidates = buildConfigClassCandidates( + primaryPackageName = context.packageName, + fallbackPackageName = readFallbackBuildConfigPackage(context), + ) + + var lastError: ClassNotFoundException? = null + for (className in candidates) { try { - val buildConfigClass = Class.forName(className) - - // Get the field from BuildConfig class - val baseUrl = safeGetValueFromBuildConfig(buildConfigClass, "FRONTEGG_DOMAIN", "") - val clientId = safeGetValueFromBuildConfig(buildConfigClass, "FRONTEGG_CLIENT_ID", "") - - val applicationId = - safeGetNullableValueFromBuildConfig(buildConfigClass, "FRONTEGG_APPLICATION_ID", "") - - val useAssetsLinks = - safeGetValueFromBuildConfig(buildConfigClass, "FRONTEGG_USE_ASSETS_LINKS", true) - val useChromeCustomTabs = safeGetValueFromBuildConfig( - buildConfigClass, "FRONTEGG_USE_CHROME_CUSTOM_TABS", true - ) - - return FronteggConstants( - baseUrl = baseUrl, - clientId = clientId, - applicationId = applicationId, - useAssetsLinks = useAssetsLinks, - useChromeCustomTabs = useChromeCustomTabs, - bundleId = this.packageName, - ) + return Class.forName(className) } catch (e: ClassNotFoundException) { - Log.e(TAG, "Class not found: $className") - throw e + Log.w(TAG, "BuildConfig not found at $className, trying next candidate") + lastError = e } } + Log.e( + TAG, + "Could not locate host BuildConfig. Tried: ${candidates.joinToString()}. " + + "If applicationId differs from your android.namespace, add " + + " to in AndroidManifest.xml." + ) + throw lastError ?: ClassNotFoundException("BuildConfig not found for ${context.packageName}") +} + +/** + * Pure-logic candidate list for `BuildConfig` class lookup. Extracted from + * [resolveBuildConfigClass] so it can be unit-tested without Android dependencies. + * + * Guarantees: + * - `primaryPackageName` is always tried first. + * - `fallbackPackageName`, if non-null and non-blank, is appended next. + * - If both inputs produce the same candidate string, only the first occurrence is kept. + */ +internal fun buildConfigClassCandidates( + primaryPackageName: String, + fallbackPackageName: String?, +): List { + val candidates = LinkedHashSet() + candidates.add("$primaryPackageName.BuildConfig") + fallbackPackageName?.takeIf { it.isNotBlank() }?.let { + candidates.add("$it.BuildConfig") + } + return candidates.toList() +} + +private fun readFallbackBuildConfigPackage(context: Context): String? = runCatching { + val appInfo = context.packageManager.getApplicationInfo( + context.packageName, + PackageManager.GET_META_DATA, + ) + appInfo.metaData?.getString(BUILD_CONFIG_PACKAGE_META) +}.getOrNull() + fun safeGetNullableValueFromBuildConfig( buildConfigClass: Class<*>, name: String, diff --git a/android/src/test/java/com/frontegg/reactnative/UtilsTest.kt b/android/src/test/java/com/frontegg/reactnative/UtilsTest.kt new file mode 100644 index 0000000..19b0b61 --- /dev/null +++ b/android/src/test/java/com/frontegg/reactnative/UtilsTest.kt @@ -0,0 +1,53 @@ +package com.frontegg.reactnative + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UtilsTest { + + @Test + fun `single candidate when fallback is null`() { + val candidates = buildConfigClassCandidates( + primaryPackageName = "com.example.app", + fallbackPackageName = null, + ) + + assertEquals(listOf("com.example.app.BuildConfig"), candidates) + } + + @Test + fun `single candidate when fallback is blank`() { + val candidates = buildConfigClassCandidates( + primaryPackageName = "com.example.app", + fallbackPackageName = " ", + ) + + assertEquals(listOf("com.example.app.BuildConfig"), candidates) + } + + @Test + fun `applicationId is always tried before fallback`() { + val candidates = buildConfigClassCandidates( + primaryPackageName = "com.example.app", + fallbackPackageName = "com.example.shared", + ) + + assertEquals( + listOf( + "com.example.app.BuildConfig", + "com.example.shared.BuildConfig", + ), + candidates, + ) + } + + @Test + fun `duplicate candidate is deduplicated when applicationId equals fallback`() { + val candidates = buildConfigClassCandidates( + primaryPackageName = "com.example.shared", + fallbackPackageName = "com.example.shared", + ) + + assertEquals(listOf("com.example.shared.BuildConfig"), candidates) + } +}