diff --git a/airborne-react-native/android/build.gradle b/airborne-react-native/android/build.gradle index a18fa5fe..c54bc421 100644 --- a/airborne-react-native/android/build.gradle +++ b/airborne-react-native/android/build.gradle @@ -147,6 +147,7 @@ dependencies { // Airborne SDK dependency api "in.juspay:airborne:2.2.7-xota.02" + } react { diff --git a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/Airborne.kt b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/Airborne.kt index b77c267e..ea7a6fd2 100644 --- a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/Airborne.kt +++ b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/Airborne.kt @@ -7,6 +7,7 @@ import `in`.juspay.airborne.LazyDownloadCallback import `in`.juspay.airborne.TrackerCallback import `in`.juspay.hyperutil.constants.LogLevel import org.json.JSONObject +import `in`.juspay.airborne.ota.OTADownloadWorker import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -58,8 +59,10 @@ class Airborne( private val applicationManager = hyperOTAServices.createApplicationManager(airborneInterface.getDimensions()) init { - airborneObjectMap.put(airborneInterface.getNamespace(), this) + airborneObjectMap[airborneInterface.getNamespace()] = this + applicationManager.shouldUpdate = airborneInterface.enableBootDownload() applicationManager.loadApplication(airborneInterface.getNamespace(), airborneInterface.getLazyDownloadCallback()) + OTADownloadWorker.managerMap[airborneInterface.getNamespace()] = applicationManager } private fun bootComplete(filePath: String) { @@ -93,16 +96,36 @@ class Airborne( return applicationManager.readReleaseConfig() } + // TODO: Re-enable once setSslConfig is added back to ApplicationManager + // /** + // * Set custom SSL configuration for mTLS support. + // * Call this before network requests are made to enable client certificate authentication. + // * + // * @param sslSocketFactory SSL socket factory configured with client certificate + // * @param trustManager Trust manager for server certificate validation + // */ + // @Keep + // fun setSslConfig(sslSocketFactory: SSLSocketFactory, trustManager: X509TrustManager) { + // applicationManager.setSslConfig(sslSocketFactory, trustManager) + // } + + /** + * Check if an OTA update is available. + * Delegates to ApplicationManager which handles the network call and version comparison. + */ + @Keep + fun checkForUpdate(): String { + return applicationManager.checkForUpdate() + } + /** - * Set custom SSL configuration for mTLS support. - * Call this before network requests are made to enable client certificate authentication. - * - * @param sslSocketFactory SSL socket factory configured with client certificate - * @param trustManager Trust manager for server certificate validation + * Download and install the latest OTA bundle. + * Delegates to ApplicationManager.downloadUpdate() which reuses the same + * download/install infrastructure as boot-time updates. */ @Keep - fun setSslConfig(sslSocketFactory: SSLSocketFactory, trustManager: X509TrustManager) { - applicationManager.setSslConfig(sslSocketFactory, trustManager) + fun downloadUpdate(onComplete: (success: Boolean) -> Unit) { + applicationManager.downloadUpdate(onComplete = onComplete) } companion object { @@ -145,7 +168,7 @@ class Airborne( // } // } - public val airborneObjectMap: MutableMap = mutableMapOf() + val airborneObjectMap: MutableMap = java.util.concurrent.ConcurrentHashMap() /** * Default LazyDownloadCallback implementation. @@ -169,5 +192,14 @@ class Airborne( } } } + + /** + * Trigger a background OTA download via WorkManager. + * Call from FCM service or any context — does not require RN. + */ + @JvmStatic + fun triggerBackgroundDownload(context: Context, namespace: String) { + OTADownloadWorker.enqueue(context, namespace) + } } } diff --git a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneInterface.kt b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneInterface.kt index abc2de7d..0aaad352 100644 --- a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneInterface.kt +++ b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneInterface.kt @@ -22,4 +22,6 @@ abstract class AirborneInterface { open fun getLazyDownloadCallback(): LazyDownloadCallback { return defaultLazyCallback } + + open fun enableBootDownload(): Boolean = true } diff --git a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModule.kt b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModule.kt index 17c62206..af6ed5a9 100644 --- a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModule.kt +++ b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModule.kt @@ -31,6 +31,26 @@ class AirborneModule(reactContext: ReactApplicationContext) : implementation.getBundlePath(namespace, promise) } + @ReactMethod + fun checkForUpdate(namespace: String, promise: Promise) { + implementation.checkForUpdate(namespace, promise) + } + + @ReactMethod + fun downloadUpdate(namespace: String, promise: Promise) { + implementation.downloadUpdate(namespace, promise) + } + + @ReactMethod + fun startBackgroundDownload(namespace: String, promise: Promise) { + implementation.startBackgroundDownload(namespace, promise) + } + + @ReactMethod + fun reloadApp(namespace: String, promise: Promise) { + implementation.reloadApp(namespace, promise) + } + companion object { const val NAME = "Airborne" } diff --git a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModuleImpl.kt b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModuleImpl.kt index fceed5b3..aec055ad 100644 --- a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModuleImpl.kt +++ b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneModuleImpl.kt @@ -1,5 +1,7 @@ package `in`.juspay.airborneplugin +import android.os.Handler +import android.os.Looper import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.Promise @@ -68,4 +70,72 @@ class AirborneModuleImpl(private val reactContext: ReactApplicationContext) { promise.reject("AIRBORNE_ERROR", "Failed to get bundle path: ${e.message}", e) } } + + fun checkForUpdate(namespace: String, promise: Promise) { + Thread { + try { + val airborne = Airborne.airborneObjectMap[namespace] + if (airborne == null) { + promise.reject("AIRBORNE_ERROR", "Airborne not initialized for namespace: $namespace") + return@Thread + } + val result = airborne.checkForUpdate() + promise.resolve(result) + } catch (e: Exception) { + promise.reject("AIRBORNE_ERROR", "Failed to check for update: ${e.message}", e) + } + }.start() + } + + fun downloadUpdate(namespace: String, promise: Promise) { + try { + val airborne = Airborne.airborneObjectMap[namespace] + if (airborne == null) { + promise.reject("AIRBORNE_ERROR", "Airborne not initialized for namespace: $namespace") + return + } + airborne.downloadUpdate { success -> + Handler(Looper.getMainLooper()).post { + promise.resolve(success) + } + } + } catch (e: Exception) { + promise.reject("AIRBORNE_ERROR", "Failed to download update: ${e.message}", e) + } + } + + fun startBackgroundDownload(namespace: String, promise: Promise) { + try { + Airborne.triggerBackgroundDownload(reactContext.applicationContext, namespace) + promise.resolve(true) + } catch (e: Exception) { + promise.reject("AIRBORNE_ERROR", "Failed to start background download: ${e.message}", e) + } + } + + fun reloadApp(namespace: String, promise: Promise) { + try { + promise.resolve(null) + + Handler(Looper.getMainLooper()).postDelayed({ + try { + val context = reactContext.applicationContext + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) + if (intent != null) { + intent.addFlags( + android.content.Intent.FLAG_ACTIVITY_NEW_TASK or + android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK + ) + context.startActivity(intent) + reactContext.currentActivity?.finishAffinity() + android.os.Process.killProcess(android.os.Process.myPid()) + } + } catch (e: Exception) { + android.util.Log.e("AirborneModuleImpl", "Failed to reload app", e) + } + }, 200) + } catch (e: Exception) { + promise.reject("AIRBORNE_ERROR", "Failed to reload app: ${e.message}", e) + } + } } diff --git a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneTurboModule.kt b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneTurboModule.kt index 04300ce4..1b5e39ad 100644 --- a/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneTurboModule.kt +++ b/airborne-react-native/android/src/main/java/in/juspay/airborneplugin/AirborneTurboModule.kt @@ -26,6 +26,22 @@ class AirborneTurboModule(reactContext: ReactApplicationContext) : implementation.getBundlePath(nameSpace, promise) } + override fun checkForUpdate(nameSpace: String, promise: Promise) { + implementation.checkForUpdate(nameSpace, promise) + } + + override fun downloadUpdate(nameSpace: String, promise: Promise) { + implementation.downloadUpdate(nameSpace, promise) + } + + override fun startBackgroundDownload(nameSpace: String, promise: Promise) { + implementation.startBackgroundDownload(nameSpace, promise) + } + + override fun reloadApp(nameSpace: String, promise: Promise) { + implementation.reloadApp(nameSpace, promise) + } + companion object { const val NAME = "AirborneReact" } diff --git a/airborne-react-native/src/NativeAirborne.ts b/airborne-react-native/src/NativeAirborne.ts index 67f767e4..bdfb3383 100644 --- a/airborne-react-native/src/NativeAirborne.ts +++ b/airborne-react-native/src/NativeAirborne.ts @@ -19,6 +19,10 @@ export interface Spec extends TurboModule { readReleaseConfig(nameSpace: string): Promise; getFileContent(nameSpace: string, filePath: string): Promise; getBundlePath(nameSpace: string): Promise; + checkForUpdate(nameSpace: string): Promise; + downloadUpdate(nameSpace: string): Promise; + startBackgroundDownload(nameSpace: string): Promise; + reloadApp(nameSpace: string): Promise; } export default TurboModuleRegistry.getEnforcing('Airborne'); diff --git a/airborne-react-native/src/index.tsx b/airborne-react-native/src/index.tsx index 398b958b..2ff46b89 100644 --- a/airborne-react-native/src/index.tsx +++ b/airborne-react-native/src/index.tsx @@ -50,4 +50,20 @@ export function getBundlePath(nameSpace: string): Promise { return Airborne.getBundlePath(nameSpace); } +export function checkForUpdate(nameSpace: string): Promise { + return Airborne.checkForUpdate(nameSpace); +} + +export function downloadUpdate(nameSpace: string): Promise { + return Airborne.downloadUpdate(nameSpace); +} + +export function startBackgroundDownload(nameSpace: string): Promise { + return Airborne.startBackgroundDownload(nameSpace); +} + +export function reloadApp(nameSpace: string): Promise { + return Airborne.reloadApp(nameSpace); +} + export default Airborne; diff --git a/airborne_sdk_android/airborne/build.gradle b/airborne_sdk_android/airborne/build.gradle index 7d4dce65..40e4818c 100644 --- a/airborne_sdk_android/airborne/build.gradle +++ b/airborne_sdk_android/airborne/build.gradle @@ -134,6 +134,8 @@ dependencies { implementation('com.google.android.material:material:1.12.0') implementation('androidx.core:core-ktx:1.1.0') implementation('com.squareup.okhttp3:okhttp:3.3.0') + implementation('androidx.work:work-runtime-ktx:2.9.1') + implementation('org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3') api ('in.juspay:hyperutil:2.2.5-xota.05') diff --git a/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/ApplicationManager.kt b/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/ApplicationManager.kt index b5992373..712eaebb 100644 --- a/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/ApplicationManager.kt +++ b/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/ApplicationManager.kt @@ -52,6 +52,7 @@ class ApplicationManager( ) { var shouldUpdate = true private lateinit var netUtils: NetUtils + @Volatile private var releaseConfig: ReleaseConfig? = null private var applicationContent = "" private val loadWaitTask = WaitTask() @@ -62,6 +63,24 @@ class ApplicationManager( private var sessionId: String? = null private var rcCallback: ReleaseConfigCallback? = null + /** + * Register or refresh this client's context in the shared CONTEXT_MAP. + * @return Pair of (initialized: whether another context already existed, contextRef: the lock object) + */ + private fun ensureContext(clientId: String): Pair { + val newRef = WeakReference(ctx) + val currentRef = CONTEXT_MAP[clientId] + val initialized = if (currentRef == null) { + CONTEXT_MAP.putIfAbsent(clientId, newRef) != null + } else if (currentRef.get() == null) { + !CONTEXT_MAP.replace(clientId, currentRef, newRef) + } else { + true + } + val contextRef = CONTEXT_MAP[clientId] ?: newRef + return Pair(initialized, contextRef) + } + fun loadApplication( unSanitizedClientId: String, lazyDownloadCallback: LazyDownloadCallback? = null @@ -73,16 +92,7 @@ class ApplicationManager( val startTime = System.currentTimeMillis() try { if (releaseConfig == null) { - val newRef = WeakReference(ctx) - val currentRef = CONTEXT_MAP[clientId] - val initialized = if (currentRef == null) { - CONTEXT_MAP.putIfAbsent(clientId, newRef) != null - } else if (currentRef.get() == null) { - !CONTEXT_MAP.replace(clientId, currentRef, newRef) - } else { - true - } - val contextRef = CONTEXT_MAP[clientId] ?: newRef + val (initialized, contextRef) = ensureContext(clientId) releaseConfig = readReleaseConfig(contextRef) if (shouldUpdate) { releaseConfig = @@ -91,21 +101,25 @@ class ApplicationManager( Log.d(TAG, "Updates disabled, running w/o updating.") } } - val rc = releaseConfig!! - indexFolderPath = getIndexFilePath(rc.pkg.index?.filePath ?: "") - indexPathWaitTask.complete() - val js = readSplit(rc.pkg.index?.filePath ?: "") - if (js.isEmpty()) { - throw IllegalStateException("index split is empty.") + val rc = releaseConfig + if (rc == null) { + Log.w(TAG, "No release config available (no disk cache, no bundled asset). Skipping load.") + } else { + indexFolderPath = getIndexFilePath(rc.pkg.index?.filePath ?: "") + indexPathWaitTask.complete() + val js = readSplit(rc.pkg.index?.filePath ?: "") + if (js.isEmpty()) { + throw IllegalStateException("index split is empty.") + } + trackBoot(rc, startTime) + Log.d(TAG, "Loading package version: ${rc.pkg.version}") + val headerJs = """ + window.document.title="${rc.pkg.name}"; + window.RELEASE_CONFIG=${rc.serialize()}; + """.trimIndent() + applicationContent = headerJs + js + loadWaitTask.complete() } - trackBoot(rc, startTime) - Log.d(TAG, "Loading package version: ${rc.pkg.version}") - val headerJs = """ - window.document.title="${rc.pkg.name}"; - window.RELEASE_CONFIG=${rc.serialize()}; - """.trimIndent() - applicationContent = headerJs + js - loadWaitTask.complete() } catch (e: Exception) { Log.e(TAG, "Critical exception while loading app! $e") trackError( @@ -150,7 +164,8 @@ class ApplicationManager( clientId: String, initialized: Boolean, fileLock: Any, - lazyDownloadCallback: LazyDownloadCallback? = null + lazyDownloadCallback: LazyDownloadCallback? = null, + packageTimeoutOverride: Long = 0L ): ReleaseConfig? { val startTime = System.currentTimeMillis() val url = if (releaseConfigTemplateUrl == "") rcCallback?.getReleaseConfig(false) else releaseConfigTemplateUrl @@ -166,7 +181,8 @@ class ApplicationManager( netUtils, rcHeaders, lazyDownloadCallback, - fromAirborne + fromAirborne, + packageTimeoutOverride ) val runningTask = RUNNING_UPDATE_TASKS.putIfAbsent(clientId, newTask) ?: newTask if (runningTask == newTask) { @@ -209,7 +225,7 @@ class ApplicationManager( ) runningTask.awaitOnFinish() releaseConfigTemplateUrl = rcCallback?.getReleaseConfig(true) ?: releaseConfigTemplateUrl - tryUpdate(clientId, true, fileLock, lazyDownloadCallback) + tryUpdate(clientId, true, fileLock, lazyDownloadCallback, packageTimeoutOverride) } else { releaseConfig } @@ -353,7 +369,11 @@ class ApplicationManager( .map { readFromInternalStorage(it) } val bundledRC = if (listOf(configString, pkgString, resString).any { it.isEmpty() }) { - ReleaseConfig.deSerialize(otaServices.fileProviderService.readFromAssets("release_config.json")).getOrNull() + val assetContent: String? = try { + otaServices.fileProviderService.readFromAssets("release_config.json") + } catch (_: Exception) { null } + if (assetContent.isNullOrEmpty()) null + else ReleaseConfig.deSerialize(assetContent).getOrNull() } else { null } @@ -423,6 +443,128 @@ class ApplicationManager( return releaseConfig?.serialize() ?: "" } + fun getCurrentPackageVersion(): String { + return releaseConfig?.pkg?.version ?: "" + } + + /** + * Check if an OTA update is available by comparing the local package version + * with the server's latest release config. + * + * @return JSON string: { available, currentVersion, serverVersion, mandatory, error? } + */ + fun checkForUpdate(): String { + try { + val currentVersion = getCurrentPackageVersion() + + val serverRCString = fetchLatestReleaseConfig() + ?: return updateCheckResult(currentVersion, error = "Failed to fetch server release config") + + val serverRC = JSONObject(serverRCString) + val serverVersion = serverRC.getJSONObject("package").getString("version") + val mandatory = serverRC.getJSONObject("config") + .optJSONObject("properties") + ?.optBoolean("mandatory", false) ?: false + + val currentVersionInt = currentVersion.toLongOrNull() ?: 0L + val serverVersionInt = serverVersion.toLongOrNull() ?: 0L + val updateAvailable = serverVersionInt > currentVersionInt + + return JSONObject() + .put("available", updateAvailable) + .put("currentVersion", currentVersion) + .put("serverVersion", serverVersion) + .put("mandatory", mandatory) + .toString() + } catch (e: Exception) { + Log.e(TAG, "checkForUpdate failed", e) + return updateCheckResult("", error = e.message ?: "Unknown error") + } + } + + private fun updateCheckResult(currentVersion: String, error: String): String { + return JSONObject() + .put("available", false) + .put("currentVersion", currentVersion) + .put("serverVersion", "") + .put("mandatory", false) + .put("error", error) + .toString() + } + + /** + * Fetch the latest release config from the server without triggering any download. + * Uses the SDK's existing network infrastructure (connection pooling, headers, caching). + * @return Serialized JSON of the server's release config, or null on failure. + */ + fun fetchLatestReleaseConfig(): String? { + try { + val clientId = sanitizeClientId(otaServices.clientId ?: return null) + if (!::netUtils.isInitialized) { + netUtils = OTANetUtils(ctx, clientId, otaServices.cleanUpValue) + } + val headers = mutableMapOf("cache-control" to "no-cache") + if (!rcHeaders.isNullOrEmpty()) { + val sortedHeaders = rcHeaders.toSortedMap() + headers["x-dimension"] = sortedHeaders.entries.joinToString(";") { "${it.key}=${it.value}" } + } + val url = if (releaseConfigTemplateUrl == "") rcCallback?.getReleaseConfig(false) else releaseConfigTemplateUrl + val resp = netUtils.doGet(url ?: releaseConfigTemplateUrl, headers, null, null, null) + val body = resp.body() + return if (resp.code() == 200 && body != null) { + body.string() + } else { + resp.close() + null + } + } catch (e: Exception) { + Log.e(TAG, "fetchLatestReleaseConfig failed", e) + return null + } + } + + /** + * Download and install the latest OTA update on-demand. + * Reuses the same download/install infrastructure as boot-time updates. + * + * @param timeoutMs Maximum time to wait for the download (default 5 minutes). + * @param onComplete Called with true on success, false on failure. + */ + fun downloadUpdate( + timeoutMs: Long = 600_000L, + onComplete: (success: Boolean) -> Unit + ) { + doAsync { + try { + val clientId = sanitizeClientId(otaServices.clientId ?: "") + val (initialized, contextRef) = ensureContext(clientId) + + // Read local config if not already loaded (e.g., boot had shouldUpdate=false) + if (releaseConfig == null) { + releaseConfig = readReleaseConfig(contextRef) + } + + val versionBefore = releaseConfig?.pkg?.version + + // Run the update with caller-specified timeout + val updatedRC = tryUpdate(clientId, initialized, contextRef, null, timeoutMs) + + if (updatedRC != null) { + releaseConfig = updatedRC + indexFolderPath = getIndexFilePath(updatedRC.pkg.index?.filePath ?: "") + } + + // Success only if version actually changed (not just a fallback to cached config) + val success = updatedRC != null && updatedRC.pkg.version != versionBefore + Log.d(TAG, "downloadUpdate: success=$success, versionBefore=$versionBefore, versionAfter=${updatedRC?.pkg?.version}") + onComplete(success) + } catch (e: Exception) { + Log.e(TAG, "downloadUpdate failed", e) + onComplete(false) + } + } + } + private fun trackUpdateResult(updateResult: UpdateResult) { val result = when (updateResult) { is UpdateResult.Ok -> "OK" diff --git a/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/OTADownloadWorker.kt b/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/OTADownloadWorker.kt new file mode 100644 index 00000000..6b54e8f4 --- /dev/null +++ b/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/OTADownloadWorker.kt @@ -0,0 +1,128 @@ +package `in`.juspay.airborne.ota + +import android.content.Context +import android.util.Log +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * WorkManager worker for background OTA bundle downloads. + * Triggered by FCM push notifications — survives process death, + * retries with exponential backoff, and has no time limit. + * + * When the download completes, the new bundle is ready for the next app launch. + */ +class OTADownloadWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + val namespace = inputData.getString(KEY_NAMESPACE) + if (namespace == null) { + Log.e(TAG, "No namespace provided") + return@withContext Result.failure() + } + + Log.d(TAG, "Starting background OTA download for namespace: $namespace") + + try { + val manager = managerMap[namespace] + + if (manager != null) { + return@withContext downloadWithManager(manager) + } + + // App was killed — ApplicationManager not initialized. + // Retry up to MAX_RETRIES times, then give up. + if (runAttemptCount >= MAX_RETRIES) { + Log.w(TAG, "ApplicationManager not found for '$namespace' after $MAX_RETRIES attempts. Giving up.") + return@withContext Result.failure() + } + Log.w(TAG, "ApplicationManager not found for '$namespace'. Will retry (attempt $runAttemptCount/$MAX_RETRIES).") + return@withContext Result.retry() + } catch (e: Exception) { + Log.e(TAG, "Background download failed for '$namespace'", e) + return@withContext Result.retry() + } + } + + private suspend fun downloadWithManager(manager: ApplicationManager): Result { + // Quick version check to avoid heavier download when already up to date + val checkResult = withContext(Dispatchers.IO) { + manager.checkForUpdate() + } + + val json = org.json.JSONObject(checkResult) + if (!json.optBoolean("available", false)) { + Log.d(TAG, "No update available, skipping download") + return Result.success() + } + + Log.d(TAG, "Update available: ${json.optString("currentVersion")} → ${json.optString("serverVersion")}") + + return suspendCoroutine { continuation -> + manager.downloadUpdate { success -> + if (success) { + Log.d(TAG, "Background download completed successfully") + continuation.resume(Result.success()) + } else { + Log.e(TAG, "Background download failed") + continuation.resume(Result.retry()) + } + } + } + } + + companion object { + private const val TAG = "OTADownloadWorker" + private const val KEY_NAMESPACE = "namespace" + private const val WORK_NAME_PREFIX = "ota_download_" + private const val MAX_RETRIES = 5 + + /** Registry of ApplicationManager instances by namespace, populated during init */ + val managerMap: MutableMap = ConcurrentHashMap() + + /** + * Enqueue a background OTA download job. + * Uses KEEP policy so duplicate FCM messages are no-ops. + */ + fun enqueue(context: Context, namespace: String) { + val request = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_NAMESPACE to namespace)) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 30, + TimeUnit.SECONDS + ) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork( + "$WORK_NAME_PREFIX$namespace", + ExistingWorkPolicy.KEEP, + request + ) + + Log.d(TAG, "Enqueued background download for '$namespace'") + } + } +} diff --git a/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/UpdateTask.kt b/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/UpdateTask.kt index 600be9c7..3e61ba80 100644 --- a/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/UpdateTask.kt +++ b/airborne_sdk_android/airborne/src/main/java/in/juspay/airborne/ota/UpdateTask.kt @@ -75,7 +75,8 @@ internal class UpdateTask( private val netUtils: NetUtils, rcHeaders: Map? = null, private val lazyDownloadCallback: LazyDownloadCallback?, - private val fromAirborne: Boolean = true + private val fromAirborne: Boolean = true, + private val packageTimeoutOverride: Long = 0L ) { val updateUUID = UUID.randomUUID().toString() @@ -96,7 +97,7 @@ internal class UpdateTask( @Volatile private var packageTimeout = - (localReleaseConfig?.config ?: DEFAULT_CONFIG).bootTimeout + if (packageTimeoutOverride > 0) packageTimeoutOverride else (localReleaseConfig?.config ?: DEFAULT_CONFIG).bootTimeout @Volatile private var currentResult: UpdateResult = UpdateResult.NA @@ -136,7 +137,7 @@ internal class UpdateTask( private fun updateTimeouts(fetchedReleaseConfig: ReleaseConfig) { releaseConfigTimeout = fetchedReleaseConfig.config.releaseConfigTimeout - packageTimeout = fetchedReleaseConfig.config.bootTimeout + packageTimeout = if (packageTimeoutOverride > 0) packageTimeoutOverride else fetchedReleaseConfig.config.bootTimeout } fun run(onFinish: OnFinishCallback) { diff --git a/airborne_server/src/release.rs b/airborne_server/src/release.rs index 7a7fcc65..529bb4ec 100644 --- a/airborne_server/src/release.rs +++ b/airborne_server/src/release.rs @@ -1310,6 +1310,12 @@ async fn serve_release_handler( .map(|v| Document::from(v.id.clone())) .collect::>(); + if !context.is_empty() && applicable_variants_ids.is_empty() { + return Err(ABError::NotFound( + "No release for the given dimensions".to_string(), + )); + } + let resolved_config_builder = context.iter().fold( state .superposition_client