diff --git a/.github/workflows/react-native-sdk-e2e.yml b/.github/workflows/react-native-sdk-e2e.yml
index db55ff1..1e0adec 100644
--- a/.github/workflows/react-native-sdk-e2e.yml
+++ b/.github/workflows/react-native-sdk-e2e.yml
@@ -40,7 +40,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: 18
+ node-version: 22
- name: Install JS dependencies
run: |
@@ -170,7 +170,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
- node-version: 18
+ node-version: 22
- name: Install JS dependencies
run: |
diff --git a/.nvmrc b/.nvmrc
index 5036cb7..2bd5a0a 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-18.10.0
+22
diff --git a/android/build.gradle b/android/build.gradle
index 77c2f9c..f5310d6 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -18,6 +18,14 @@ def isNewArchitectureEnabled() {
return rootProject.hasProperty("newArchEnabled") && rootProject.getProperty("newArchEnabled") == "true"
}
+def supportsNamespace() {
+ def parsed = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')
+ def major = parsed[0].toInteger()
+ def minor = parsed[1].toInteger()
+
+ return (major == 7 && minor >= 3) || major >= 8
+}
+
apply plugin: "com.android.library"
apply plugin: "kotlin-android"
@@ -37,6 +45,16 @@ def getExtOrIntegerDefault(name) {
}
android {
+ if (supportsNamespace()) {
+ namespace "com.frontegg.reactnative"
+
+ sourceSets {
+ main {
+ manifest.srcFile "src/main/AndroidManifestNew.xml"
+ }
+ }
+ }
+
compileSdkVersion getExtOrIntegerDefault("compileSdkVersion")
defaultConfig {
@@ -44,6 +62,10 @@ android {
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
}
+ buildFeatures {
+ buildConfig true
+ }
+
buildTypes {
release {
minifyEnabled false
@@ -68,6 +90,19 @@ android {
useJUnit()
}
}
+
+ sourceSets {
+ main {
+ if (isNewArchitectureEnabled()) {
+ java.srcDirs += [
+ "src/newarch",
+ "${project.buildDir}/generated/source/codegen/java"
+ ]
+ } else {
+ java.srcDirs += ["src/oldarch"]
+ }
+ }
+ }
}
repositories {
@@ -93,7 +128,7 @@ dependencies {
if (isNewArchitectureEnabled()) {
react {
jsRootDir = file("../src/")
- libraryName = "ReactNative"
+ libraryName = "RNFronteggRNSpec"
codegenJavaPackageName = "com.frontegg.reactnative"
}
}
diff --git a/android/src/main/AndroidManifestNew.xml b/android/src/main/AndroidManifestNew.xml
new file mode 100644
index 0000000..963e675
--- /dev/null
+++ b/android/src/main/AndroidManifestNew.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt b/android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt
new file mode 100644
index 0000000..65fa70b
--- /dev/null
+++ b/android/src/main/java/com/frontegg/reactnative/AccessTokenRefresher.kt
@@ -0,0 +1,102 @@
+package com.frontegg.reactnative
+
+import android.content.Context
+import com.frontegg.android.FronteggAuth
+import com.frontegg.android.utils.FronteggConstantsProvider
+import com.frontegg.android.utils.JWTHelper
+import com.frontegg.android.utils.NetworkGate
+import java.io.IOException
+import java.time.Instant
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withTimeoutOrNull
+
+private const val MINIMUM_ACCESS_TOKEN_VALIDITY_SECONDS = 15
+private const val AUTH_STATE_SETTLE_TIMEOUT_MS = 20_000L
+private const val CONCURRENT_REFRESH_TIMEOUT_MS = 10_000L
+private const val AUTH_STATE_POLL_INTERVAL_MS = 200L
+
+internal suspend fun fetchValidAccessToken(auth: FronteggAuth, context: Context): String? {
+ if (auth.isMultiRegion && auth.selectedRegion == null) {
+ return null
+ }
+ val settled = waitWhile(AUTH_STATE_SETTLE_TIMEOUT_MS) {
+ auth.initializing.value || auth.refreshingToken.value
+ }
+ if (!settled) {
+ throw IllegalStateException("Timed out waiting for authentication state to settle")
+ }
+ if (auth.refreshToken.value == null) {
+ return null
+ }
+
+ val currentAccessToken = auth.accessToken.value
+ if (hasMoreThanMinimumValidity(currentAccessToken)) {
+ return currentAccessToken
+ }
+
+ val offlineModeEnabled = isOfflineModeEnabled(context)
+ if (!NetworkGate.isNetworkLikelyGood(context)) {
+ if (offlineModeEnabled) {
+ return currentAccessToken
+ }
+ throw IOException("No network connection to refresh the access token")
+ }
+
+ val refreshedAccessToken = refreshUntilValid(auth)
+ if (refreshedAccessToken != null) {
+ return refreshedAccessToken
+ }
+ if (auth.refreshToken.value == null) {
+ return null
+ }
+ if (offlineModeEnabled && !NetworkGate.isNetworkLikelyGood(context)) {
+ return auth.accessToken.value
+ }
+ throw IOException("Access token could not be refreshed")
+}
+
+private suspend fun refreshUntilValid(auth: FronteggAuth): String? {
+ if (!auth.refreshTokenAndWait()) {
+ return null
+ }
+ if (hasMoreThanMinimumValidity(auth.accessToken.value)) {
+ return auth.accessToken.value
+ }
+ waitWhile(CONCURRENT_REFRESH_TIMEOUT_MS) { auth.refreshingToken.value }
+ if (hasMoreThanMinimumValidity(auth.accessToken.value)) {
+ return auth.accessToken.value
+ }
+ if (!auth.refreshTokenAndWait()) {
+ return null
+ }
+ return auth.accessToken.value?.takeIf { hasMoreThanMinimumValidity(it) }
+}
+
+private suspend fun waitWhile(timeoutMs: Long, condition: () -> Boolean): Boolean {
+ val finished = withTimeoutOrNull(timeoutMs) {
+ while (condition()) {
+ delay(AUTH_STATE_POLL_INTERVAL_MS)
+ }
+ true
+ }
+ return finished == true
+}
+
+// Same source the SDK is initialized from, both by its auto-init and by RegionsInitializer.
+private fun isOfflineModeEnabled(context: Context): Boolean {
+ return runCatching {
+ FronteggConstantsProvider.fronteggConstants(context).enableOfflineMode
+ }.getOrDefault(false)
+}
+
+private fun hasMoreThanMinimumValidity(accessToken: String?): Boolean {
+ if (accessToken == null) {
+ return false
+ }
+ return try {
+ val secondsLeft = JWTHelper.decode(accessToken).exp - Instant.now().epochSecond
+ secondsLeft > MINIMUM_ACCESS_TOKEN_VALIDITY_SECONDS
+ } catch (e: Exception) {
+ false
+ }
+}
diff --git a/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt b/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt
index f7fc392..82e4b40 100644
--- a/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt
+++ b/android/src/main/java/com/frontegg/reactnative/FronteggConstants.kt
@@ -6,7 +6,8 @@ data class FronteggConstants(
val applicationId: String?,
val useAssetsLinks: Boolean,
val useChromeCustomTabs: Boolean,
- val bundleId: String
+ val bundleId: String,
+ val regionsJson: String? = null,
) {
fun toMap(): MutableMap {
return mutableMapOf(
diff --git a/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt b/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt
index 65faa9a..b49017c 100644
--- a/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt
+++ b/android/src/main/java/com/frontegg/reactnative/FronteggRNModule.kt
@@ -6,13 +6,13 @@ import android.os.Looper
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableMap
import com.facebook.react.common.LifecycleState
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.frontegg.android.AdminPortalActivity
+import com.frontegg.android.fronteggApp
import com.frontegg.android.exceptions.CanceledByUserException
import com.frontegg.android.exceptions.FailedToAuthenticateException
import com.frontegg.android.fronteggAuth
@@ -31,7 +31,7 @@ import kotlin.time.toDuration
class FronteggRNModule(val reactContext: ReactApplicationContext) :
- ReactContextBaseJavaModule(reactContext) {
+ FronteggRNSpec(reactContext) {
private val fronteggConstants: FronteggConstants
private var disposable: Disposable? = null
@@ -43,6 +43,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
init {
fronteggConstants = reactContext.fronteggConstants
+ initRegionsFromBuildConfig(reactContext.applicationContext, fronteggConstants)
// SDK 1.3+ auto-initializes from BuildConfig when first accessing context.fronteggAuth
}
@@ -60,7 +61,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
// https://reactnative.dev/docs/native-modules-android
@ReactMethod
- fun subscribe() {
+ override fun subscribe() {
if (this.disposable != null) {
this.disposable!!.dispose()
}
@@ -111,6 +112,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
putBoolean("isLoading", isLoading)
putBoolean("initializing", initializing)
putBoolean("showLoader", showLoader)
+ putMap("selectedRegion", auth.selectedRegion?.toWritableMap())
}
@@ -118,7 +120,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun logout(promise: Promise) {
+ override fun logout(promise: Promise) {
// Resolve only after the SDK reports the logout finished, so JS can
// await the actual end of the session (parity with the iOS bridge).
//
@@ -142,17 +144,17 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun addListener(eventName: String?) {
+ override fun addListener(eventName: String?) {
}
@ReactMethod
- fun removeListeners(count: Int) {
+ override fun removeListeners(count: Double) {
}
@ReactMethod
- fun login(loginHint: String?, promise: Promise) {
+ override fun login(loginHint: String?, promise: Promise) {
withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity ->
auth.login(activity, loginHint) { error ->
resolveOrRejectLogin(error, promise)
@@ -166,7 +168,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
* [login] when this method is absent, losing the theming rather than the sign-in.
*/
@ReactMethod
- fun loginWithOptions(loginHint: String?, customization: ReadableMap?, promise: Promise) {
+ override fun loginWithOptions(loginHint: String?, customization: ReadableMap?, promise: Promise) {
withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity ->
applyLoginBoxCustomization(customization)
auth.login(activity, loginHint) { error ->
@@ -176,7 +178,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun isLoginBoxCustomizationSupported(promise: Promise) {
+ override fun isLoginBoxCustomizationSupported(promise: Promise) {
promise.resolve(LoginBoxCustomization.isSupported())
}
@@ -193,14 +195,14 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun switchTenant(tenantId: String, promise: Promise) {
+ override fun switchTenant(tenantId: String, promise: Promise) {
auth.switchTenant(tenantId) { success ->
resolveTenantSwitch(success, tenantId, promise)
}
}
@ReactMethod
- fun directLoginAction(
+ override fun directLoginAction(
type: String,
data: String,
ephemeralSession: Boolean,
@@ -222,7 +224,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun refreshToken(promise: Promise) {
+ override fun refreshToken(promise: Promise) {
// FR-25937: refreshTokenIfNeeded() starts the refresh in the background and returns
// immediately, so resolving here handed JS a stale token. refreshTokenAndWait() suspends
// until the refresh finishes; resolve its Boolean result to match iOS (which awaits a Bool).
@@ -237,7 +239,35 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun loginWithPasskeys(promise: Promise) {
+ override fun getOrRefreshAccessToken(promise: Promise) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ promise.resolve(fetchValidAccessToken(auth, reactContext.applicationContext))
+ } catch (e: Exception) {
+ promise.reject("GET_OR_REFRESH_ACCESS_TOKEN_ERROR", e.message, e)
+ }
+ }
+ }
+
+ @ReactMethod
+ override fun initWithRegion(regionKey: String, promise: Promise) {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ if (auth.regions.none { it.key == regionKey }) {
+ promise.reject("INVALID_REGION", "Region '$regionKey' is not configured")
+ return@launch
+ }
+ reactContext.applicationContext.fronteggApp.initWithRegion(regionKey)
+ notifyChanges()
+ promise.resolve(null)
+ } catch (e: Exception) {
+ promise.reject("INIT_WITH_REGION_ERROR", e.message, e)
+ }
+ }
+ }
+
+ @ReactMethod
+ override fun loginWithPasskeys(promise: Promise) {
withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity ->
auth.loginWithPasskeys(activity) { error ->
if (error != null) {
@@ -250,14 +280,14 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun isSteppedUp(maxAgeSeconds: Double, promise: Promise) {
+ override fun isSteppedUp(maxAgeSeconds: Double, promise: Promise) {
val maxAge =
if (maxAgeSeconds < 0) null else maxAgeSeconds.toDuration(DurationUnit.SECONDS)
promise.resolve(auth.isSteppedUp(maxAge))
}
@ReactMethod
- fun stepUp(maxAgeSeconds: Double, promise: Promise) {
+ override fun stepUp(maxAgeSeconds: Double, promise: Promise) {
val activity = reactApplicationContext.currentActivity
if (activity == null) {
promise.reject("NO_ACTIVITY", "Current activity is null")
@@ -275,7 +305,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun requestAuthorize(refreshToken: String, deviceTokenCookie: String?, promise: Promise) {
+ override fun requestAuthorize(refreshToken: String, deviceTokenCookie: String?, promise: Promise) {
try {
auth.requestAuthorize(refreshToken, deviceTokenCookie) { result ->
result.fold(
@@ -295,7 +325,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
@ReactMethod
- fun registerPasskeys(promise: Promise) {
+ override fun registerPasskeys(promise: Promise) {
withActivityOrReject(reactApplicationContext.currentActivity, promise) { activity ->
auth.registerPasskeys(activity) { error ->
if (error != null) {
@@ -308,7 +338,7 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun openAdminPortal(promise: Promise) {
+ override fun openAdminPortal(promise: Promise) {
val activity = reactApplicationContext.currentActivity
if (activity == null) {
promise.reject("NO_ACTIVITY", "Cannot open Admin Portal without an active Activity")
@@ -320,19 +350,19 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
}
@ReactMethod
- fun loadEntitlements(forceRefresh: Boolean, promise: Promise) {
+ override fun loadEntitlements(forceRefresh: Boolean, promise: Promise) {
auth.loadEntitlements(forceRefresh) { success ->
promise.resolve(success)
}
}
@ReactMethod
- fun getFeatureEntitlement(key: String, promise: Promise) {
+ override fun getFeatureEntitlement(key: String, promise: Promise) {
promise.resolve(entitlementToMap(auth.getFeatureEntitlements(key)))
}
@ReactMethod
- fun getPermissionEntitlement(key: String, promise: Promise) {
+ override fun getPermissionEntitlement(key: String, promise: Promise) {
promise.resolve(entitlementToMap(auth.getPermissionEntitlements(key)))
}
@@ -342,7 +372,11 @@ class FronteggRNModule(val reactContext: ReactApplicationContext) :
putString("justification", entitlement.justification)
}
- override fun getConstants(): MutableMap = fronteggConstants.toMap()
+ override fun getTypedExportedConstants(): Map =
+ fronteggConstants.toMap().apply {
+ put("isRegional", auth.isMultiRegion)
+ put("regionData", auth.regions.map { it.toMap() })
+ }
companion object {
const val NAME = "FronteggRN"
diff --git a/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt b/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt
index 9030256..c47bb99 100644
--- a/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt
+++ b/android/src/main/java/com/frontegg/reactnative/FronteggRNPackage.kt
@@ -1,17 +1,34 @@
package com.frontegg.reactnative
-import com.facebook.react.ReactPackage
+import com.facebook.react.TurboReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
-import com.facebook.react.uimanager.ViewManager
+import com.facebook.react.module.model.ReactModuleInfo
+import com.facebook.react.module.model.ReactModuleInfoProvider
-class FronteggRNPackage : ReactPackage {
- override fun createNativeModules(reactContext: ReactApplicationContext): List {
- return listOf(FronteggRNModule(reactContext))
+class FronteggRNPackage : TurboReactPackage() {
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
+ return if (name == FronteggRNModule.NAME) {
+ FronteggRNModule(reactContext)
+ } else {
+ null
+ }
}
- override fun createViewManagers(reactContext: ReactApplicationContext): List> {
- return emptyList()
+ override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
+ return ReactModuleInfoProvider {
+ mapOf(
+ FronteggRNModule.NAME to ReactModuleInfo(
+ FronteggRNModule.NAME,
+ FronteggRNModule.NAME,
+ false,
+ false,
+ true,
+ false,
+ BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
+ )
+ )
+ }
}
}
diff --git a/android/src/main/java/com/frontegg/reactnative/Parser.kt b/android/src/main/java/com/frontegg/reactnative/Parser.kt
index 1b02c9a..ceaa5fa 100644
--- a/android/src/main/java/com/frontegg/reactnative/Parser.kt
+++ b/android/src/main/java/com/frontegg/reactnative/Parser.kt
@@ -3,12 +3,23 @@ package com.frontegg.reactnative
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.WritableArray
+import com.facebook.react.bridge.WritableMap
import com.frontegg.android.models.User
+import com.frontegg.android.regions.RegionConfig
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import com.google.gson.Gson
+fun RegionConfig.toMap(): Map = mapOf(
+ "key" to key,
+ "baseUrl" to baseUrl,
+ "clientId" to clientId,
+ "applicationId" to applicationId,
+)
+
+fun RegionConfig.toWritableMap(): WritableMap = Arguments.makeNativeMap(toMap())
+
fun User.toReadableMap(): ReadableMap? {
val jsonStr = Gson().toJson(this, User::class.java)
val jsonObject = JSONObject(jsonStr)
diff --git a/android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt b/android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt
new file mode 100644
index 0000000..3dc5229
--- /dev/null
+++ b/android/src/main/java/com/frontegg/reactnative/RegionsInitializer.kt
@@ -0,0 +1,91 @@
+package com.frontegg.reactnative
+
+import android.content.Context
+import android.util.Log
+import com.frontegg.android.FronteggApp
+import com.frontegg.android.regions.RegionConfig
+import com.frontegg.android.utils.FronteggConstantsProvider
+import com.frontegg.android.utils.TenantResolver
+import com.frontegg.android.utils.TenantResolverResult
+import java.util.concurrent.atomic.AtomicBoolean
+import org.json.JSONArray
+import org.json.JSONException
+
+private val regionsInitialized = AtomicBoolean(false)
+
+object FronteggRegions {
+ @JvmStatic
+ fun initFromBuildConfig(context: Context) {
+ val applicationContext = context.applicationContext
+ initRegionsFromBuildConfig(applicationContext, applicationContext.fronteggConstants)
+ }
+}
+
+internal fun initRegionsFromBuildConfig(context: Context, constants: FronteggConstants) {
+ val regionsJson = constants.regionsJson?.takeIf { it.isNotBlank() } ?: return
+ if (!regionsInitialized.compareAndSet(false, true)) {
+ return
+ }
+ val regions = try {
+ parseRegions(regionsJson)
+ } catch (e: JSONException) {
+ Log.e(TAG, "FRONTEGG_REGIONS must be a JSON array of {key, baseUrl, clientId, applicationId?}", e)
+ return
+ }
+ try {
+ initSdkWithRegions(context, constants, regions)
+ } catch (e: Exception) {
+ regionsInitialized.set(false)
+ Log.e(TAG, "Could not initialize the Frontegg SDK with FRONTEGG_REGIONS, will retry on the next call", e)
+ }
+}
+
+private fun initSdkWithRegions(
+ context: Context,
+ constants: FronteggConstants,
+ regions: List,
+) {
+ val sdkConstants = FronteggConstantsProvider.fronteggConstants(context)
+ FronteggApp.initWithRegions(
+ regions = regions,
+ context = context,
+ useAssetsLinks = constants.useAssetsLinks,
+ useChromeCustomTabs = constants.useChromeCustomTabs,
+ mainActivityClass = findClassOrNull(sdkConstants.mainActivityClass),
+ useDiskCacheWebview = sdkConstants.useDiskCacheWebview,
+ disableAutoRefresh = sdkConstants.disableAutoRefresh,
+ enableOfflineMode = sdkConstants.enableOfflineMode,
+ networkMonitoringIntervalSeconds = sdkConstants.networkMonitoringIntervalSeconds,
+ enableSessionPerTenant = sdkConstants.enableSessionPerTenant,
+ entitlementsEnabled = sdkConstants.entitlementsEnabled,
+ tenantResolver = sdkConstants.fronteggOrganization?.let { organization ->
+ TenantResolver { TenantResolverResult(organization) }
+ },
+ )
+}
+
+private fun findClassOrNull(className: String?): Class<*>? {
+ if (className == null) {
+ return null
+ }
+ return try {
+ Class.forName(className)
+ } catch (e: ClassNotFoundException) {
+ Log.e(TAG, "FRONTEGG_MAIN_ACTIVITY_CLASS ($className) not found", e)
+ null
+ }
+}
+
+internal fun parseRegions(regionsJson: String): List {
+ val regionsArray = JSONArray(regionsJson)
+ return (0 until regionsArray.length()).map { index ->
+ val region = regionsArray.getJSONObject(index)
+ val applicationId = if (region.isNull("applicationId")) null else region.getString("applicationId")
+ RegionConfig(
+ region.getString("key"),
+ region.getString("baseUrl"),
+ region.getString("clientId"),
+ applicationId,
+ )
+ }
+}
diff --git a/android/src/main/java/com/frontegg/reactnative/Utils.kt b/android/src/main/java/com/frontegg/reactnative/Utils.kt
index 9f1f029..0ee7d57 100644
--- a/android/src/main/java/com/frontegg/reactnative/Utils.kt
+++ b/android/src/main/java/com/frontegg/reactnative/Utils.kt
@@ -33,6 +33,8 @@ val Context.fronteggConstants: FronteggConstants
val useChromeCustomTabs = safeGetValueFromBuildConfig(
buildConfigClass, "FRONTEGG_USE_CHROME_CUSTOM_TABS", true
)
+ val regionsJson =
+ safeGetNullableValueFromBuildConfig(buildConfigClass, "FRONTEGG_REGIONS", "")
return FronteggConstants(
baseUrl = baseUrl,
@@ -41,6 +43,7 @@ val Context.fronteggConstants: FronteggConstants
useAssetsLinks = useAssetsLinks,
useChromeCustomTabs = useChromeCustomTabs,
bundleId = this.packageName,
+ regionsJson = regionsJson,
)
} catch (e: ClassNotFoundException) {
throw e
diff --git a/android/src/newarch/FronteggRNSpec.kt b/android/src/newarch/FronteggRNSpec.kt
new file mode 100644
index 0000000..be0fb6d
--- /dev/null
+++ b/android/src/newarch/FronteggRNSpec.kt
@@ -0,0 +1,6 @@
+package com.frontegg.reactnative
+
+import com.facebook.react.bridge.ReactApplicationContext
+
+abstract class FronteggRNSpec internal constructor(context: ReactApplicationContext) :
+ NativeFronteggRNSpec(context)
diff --git a/android/src/oldarch/FronteggRNSpec.kt b/android/src/oldarch/FronteggRNSpec.kt
new file mode 100644
index 0000000..fb33739
--- /dev/null
+++ b/android/src/oldarch/FronteggRNSpec.kt
@@ -0,0 +1,42 @@
+package com.frontegg.reactnative
+
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReadableMap
+
+abstract class FronteggRNSpec internal constructor(context: ReactApplicationContext) :
+ ReactContextBaseJavaModule(context) {
+
+ protected abstract fun getTypedExportedConstants(): Map
+
+ override fun getConstants(): Map = getTypedExportedConstants()
+
+ abstract fun subscribe()
+ abstract fun addListener(eventName: String?)
+ abstract fun removeListeners(count: Double)
+ abstract fun login(loginHint: String?, promise: Promise)
+ abstract fun loginWithOptions(loginHint: String?, customization: ReadableMap?, promise: Promise)
+ abstract fun isLoginBoxCustomizationSupported(promise: Promise)
+ abstract fun logout(promise: Promise)
+ abstract fun switchTenant(tenantId: String, promise: Promise)
+ abstract fun directLoginAction(
+ type: String,
+ data: String,
+ ephemeralSession: Boolean,
+ additionalQueryParams: ReadableMap?,
+ promise: Promise
+ )
+ abstract fun refreshToken(promise: Promise)
+ abstract fun getOrRefreshAccessToken(promise: Promise)
+ abstract fun loginWithPasskeys(promise: Promise)
+ abstract fun registerPasskeys(promise: Promise)
+ abstract fun requestAuthorize(refreshToken: String, deviceTokenCookie: String?, promise: Promise)
+ abstract fun isSteppedUp(maxAgeSeconds: Double, promise: Promise)
+ abstract fun stepUp(maxAgeSeconds: Double, promise: Promise)
+ abstract fun openAdminPortal(promise: Promise)
+ abstract fun loadEntitlements(forceRefresh: Boolean, promise: Promise)
+ abstract fun getFeatureEntitlement(key: String, promise: Promise)
+ abstract fun getPermissionEntitlement(key: String, promise: Promise)
+ abstract fun initWithRegion(regionKey: String, promise: Promise)
+}
diff --git a/app.plugin.js b/app.plugin.js
new file mode 100644
index 0000000..3c7d11b
--- /dev/null
+++ b/app.plugin.js
@@ -0,0 +1 @@
+module.exports = require('./plugin/build');
diff --git a/docs/README.md b/docs/README.md
index 1664368..13a8f19 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -71,6 +71,19 @@ client ID from `build.gradle`. Both are covered step by step in the
[Setup guide](https://react-native-guide.frontegg.com/#/setup) — this is the one part that is not
JavaScript, and it differs per platform.
+On Expo, add the config plugin to `app.json` instead and run `npx expo prebuild` — it generates both
+native configurations for you ([Expo setup](https://react-native-guide.frontegg.com/#/setup?id=expo)):
+
+```json
+{
+ "expo": {
+ "plugins": [
+ ["@frontegg/react-native", { "baseUrl": "https://{{FRONTEGG_BASE_URL}}", "clientId": "{{FRONTEGG_CLIENT_ID}}" }]
+ ]
+ }
+}
+```
+
**3 · Wrap your app.**
```tsx
diff --git a/docs/advanced.md b/docs/advanced.md
index e9bbe9c..56f3e84 100644
--- a/docs/advanced.md
+++ b/docs/advanced.md
@@ -45,6 +45,92 @@ buildConfigField "String", "FRONTEGG_APPLICATION_ID", "\"$fronteggApplicationId\
```
+### Multi-region support
+
+Configure every region natively, then let the user pick one from JavaScript. The selection is persisted, so the SDK starts in the same region on the next launch.
+
+#### iOS setup
+
+Replace `baseUrl`/`clientId` in `Frontegg.plist` with a `regions` array:
+
+```xml
+regions
+
+
+ key
+ eu
+ baseUrl
+ https://{{FRONTEGG_EU_DOMAIN}}
+ clientId
+ {{FRONTEGG_EU_CLIENT_ID}}
+
+
+ key
+ us
+ baseUrl
+ https://{{FRONTEGG_US_DOMAIN}}
+ clientId
+ {{FRONTEGG_US_CLIENT_ID}}
+
+
+```
+
+Each entry also accepts an optional `applicationId`. Add an associated domain for every region's domain.
+
+#### Android setup
+
+Add a `FRONTEGG_REGIONS` BuildConfig field to `android/app/build.gradle`, holding a JSON array of `{ key, baseUrl, clientId, applicationId? }`. The SDK then initializes with these regions instead of `FRONTEGG_DOMAIN`/`FRONTEGG_CLIENT_ID`:
+
+```groovy
+def fronteggRegions = '[{"key":"eu","baseUrl":"{{FRONTEGG_EU_DOMAIN}}","clientId":"{{FRONTEGG_EU_CLIENT_ID}}"},{"key":"us","baseUrl":"{{FRONTEGG_US_DOMAIN}}","clientId":"{{FRONTEGG_US_CLIENT_ID}}"}]'
+
+android {
+ defaultConfig {
+ buildConfigField "String", "FRONTEGG_REGIONS", "\"${fronteggRegions.replace('"', '\\"')}\""
+ }
+}
+```
+
+Then initialize the regions when the app process starts, so a login redirect that relaunches the app finds them configured before React Native loads. Call the helper right after `super.onCreate()` in `MainApplication`:
+
+```kotlin
+import com.frontegg.reactnative.FronteggRegions
+
+override fun onCreate() {
+ super.onCreate()
+ FronteggRegions.initFromBuildConfig(this)
+ // ...
+}
+```
+
+From a Java `MainApplication`, call `FronteggRegions.initFromBuildConfig(this);`. The Expo config plugin adds this call for you. Without it the module still initializes the regions when React Native first loads it, but a process restarted straight into the login redirect would start without them.
+
+Alternatively, call `FronteggApp.initWithRegions(...)` yourself in `MainApplication.onCreate()` and omit the field; do not do both. Add intent filters (and asset links, when used) for every region's domain, as described in the [Android SDK multi-region guide](https://github.com/frontegg/frontegg-android-kotlin#multi-region-support).
+
+#### Select a region
+
+```tsx
+import { Button } from 'react-native';
+import { getRegions, initWithRegion, useAuth } from '@frontegg/react-native';
+
+export function RegionPicker() {
+ const { selectedRegion } = useAuth();
+
+ if (selectedRegion) {
+ return null;
+ }
+ return getRegions().map((region) => (
+