Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,20 @@ import javax.net.ssl.X509TrustManager

@Keep
class Airborne(
context: Context,
releaseConfigUrl: String,
private val airborneInterface: AirborneInterface
private val context: Context,
private val releaseConfigUrl: String,
private val airborneInterface: AirborneInterface,
private val shouldUpdate: Boolean
) {

constructor(context: Context, releaseConfigUrl: String) : this(context, releaseConfigUrl, object : AirborneInterface() {})
constructor(context: Context, releaseConfigUrl: String) : this(context, releaseConfigUrl, object : AirborneInterface() {}, true)

constructor(context: Context, releaseConfigUrl: String, airborneInterface: AirborneInterface) : this(
context,
releaseConfigUrl,
airborneInterface,
true
)

/**
* Default no-op TrackerCallback.
Expand Down Expand Up @@ -59,6 +67,7 @@ class Airborne(

init {
airborneObjectMap.put(airborneInterface.getNamespace(), this)
applicationManager.shouldUpdate = shouldUpdate
applicationManager.loadApplication(airborneInterface.getNamespace(), airborneInterface.getLazyDownloadCallback())
}

Expand Down Expand Up @@ -94,15 +103,13 @@ class Airborne(
}

/**
* 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
* Checks for updates by fetching the remote RC and comparing with local.
* Uses the dimensions provided via AirborneInterface at init time.
* @return JSON string with update metadata.
*/
@Keep
fun setSslConfig(sslSocketFactory: SSLSocketFactory, trustManager: X509TrustManager) {
applicationManager.setSslConfig(sslSocketFactory, trustManager)
fun checkForUpdate(): String {
return applicationManager.checkForUpdate()
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ class AirborneModule(reactContext: ReactApplicationContext) :
implementation.getBundlePath(namespace, promise)
}

@ReactMethod
fun checkForUpdate(namespace: String, promise: Promise) {
implementation.checkForUpdate(namespace, promise)
}

companion object {
const val NAME = "Airborne"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,18 @@ class AirborneModuleImpl(private val reactContext: ReactApplicationContext) {
promise.reject("AIRBORNE_ERROR", "Failed to get bundle path: ${e.message}", e)
}
}

fun checkForUpdate(namespace: String, promise: Promise) {
try {
val airborne = Airborne.airborneObjectMap[namespace]
if (airborne == null) {
promise.reject("UNKNOWN_NAMESPACE", "Namespace not found: $namespace")
return
}
val result = airborne.checkForUpdate()
promise.resolve(result)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (e: Exception) {
promise.reject("AIRBORNE_ERROR", "Failed to check for update: ${e.message}", e)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ class AirborneTurboModule(reactContext: ReactApplicationContext) :
implementation.getBundlePath(nameSpace, promise)
}

override fun checkForUpdate(nameSpace: String, promise: Promise) {
implementation.checkForUpdate(nameSpace, promise)
}

companion object {
const val NAME = "AirborneReact"
}
Expand Down
21 changes: 21 additions & 0 deletions airborne-react-native/example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
readReleaseConfig,
getFileContent,
getBundlePath,
checkForUpdate,
} from 'airborne-react-native';

export default function App() {
const [releaseConfig, setReleaseConfig] = useState<string | undefined>();
const [bundlePath, setBundlePath] = useState<string | undefined>();
const [fileContent, setFileContent] = useState<string | undefined>();
const [isInitialized, setIsInitialized] = useState(false);
const [updateMessage, setUpdateMessage] = useState("");

// Airborne is initialized in native code (MainApplication.kt for Android, AppDelegate.swift for iOS)
// This ensures the instance is ready before React Native starts
Expand All @@ -22,6 +24,15 @@ export default function App() {
.catch(() => setIsInitialized(false));
}, []);

const handleCheckForUpdate = async () => {
try {
const resp = await checkForUpdate("airborne-example");
setUpdateMessage(resp);
}catch(err: any) {
Alert.alert('Error', err.message || 'Failed to check for update');
}
}

const handleReadReleaseConfig = async () => {
try {
const config = await readReleaseConfig("airborne-example");
Expand Down Expand Up @@ -66,6 +77,16 @@ export default function App() {
)}
</View>

<View style={styles.section}>
<Button
title="Check for update"
onPress={handleCheckForUpdate}
/>
{updateMessage && (
<Text style={styles.result}>File Content: {updateMessage}</Text>
)}
</View>

<View style={styles.section}>
<Button title="Get Bundle Path" onPress={handleGetBundlePath} />
{bundlePath && (
Expand Down
2 changes: 2 additions & 0 deletions airborne-react-native/ios/Airborne.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ NS_ASSUME_NONNULL_BEGIN
+ (instancetype)sharedInstanceWithNamespace:(NSString *)aNamespace;

- (instancetype)initWithReleaseConfigURL:(NSString *)releaseConfigURL delegate:(id<AirborneDelegate>)delegate;
- (instancetype)initWithReleaseConfigURL:(NSString *)releaseConfigURL delegate:(id<AirborneDelegate>)delegate shouldUpdate:(BOOL)shouldUpdate;

- (NSString *)getBundlePath;
- (NSString *)getFileContent:(NSString *)filePath;
- (NSString *)getReleaseConfig;
- (void)checkForUpdate:(void (^)(NSString * _Nonnull))completion;

@end

Expand Down
16 changes: 13 additions & 3 deletions airborne-react-native/ios/Airborne.m
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ @interface Airborne() <AirborneDelegate>

@property (nonatomic, strong) NSString* namespace;
@property (nonatomic, strong) AirborneServices* airborne;
@property (nonatomic, weak) id <AirborneDelegate> delegate;

@end

Expand Down Expand Up @@ -51,9 +50,13 @@ - (instancetype)initWithNamespace:(NSString *)namespace {
}

- (instancetype)initWithReleaseConfigURL:(NSString *)releaseConfigURL delegate:(id<AirborneDelegate>)delegate {
return [self initWithReleaseConfigURL:releaseConfigURL delegate:delegate shouldUpdate:YES];
}

- (instancetype)initWithReleaseConfigURL:(NSString *)releaseConfigURL delegate:(id<AirborneDelegate>)delegate shouldUpdate:(BOOL)shouldUpdate {
self = [super init];
if (self) {
self.airborne = [[AirborneServices alloc] initWithReleaseConfigURL:releaseConfigURL delegate:delegate ?: self];
self.airborne = [[AirborneServices alloc] initWithReleaseConfigURL:releaseConfigURL delegate:delegate ?: self shouldUpdate:shouldUpdate];
}
return self;
}
Expand All @@ -70,11 +73,18 @@ - (NSString *)getReleaseConfig {
return [self.airborne getReleaseConfig];
}

- (void)checkForUpdate:(void (^)(NSString * _Nonnull))completion {
if (!self.airborne) {
completion(@"{\"updateAvailable\":false,\"error\":\"Airborne is not initialized\"}");
return;
}
[self.airborne checkForUpdate:completion];
}

#pragma mark - AirborneDelegate

- (NSString *)namespace {
return @"default";
}

@end

24 changes: 24 additions & 0 deletions airborne-react-native/ios/AirborneReact.mm
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,18 @@ - (void)getBundlePath:(RCTPromiseResolveBlock)resolve
reject(@"AIRBORNE_ERROR", exception.reason, nil);
}
}

- (void)checkForUpdate:(NSString *)nameSpace
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject {
@try {
[[Airborne sharedInstanceWithNamespace:nameSpace] checkForUpdate:^(NSString * _Nonnull result) {
resolve(result);
}];
} @catch (NSException *exception) {
reject(@"AIRBORNE_ERROR", exception.reason, nil);
}
}
#else
RCT_EXPORT_METHOD(readReleaseConfig:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
Expand Down Expand Up @@ -89,6 +101,18 @@ - (void)getBundlePath:(RCTPromiseResolveBlock)resolve
reject(@"AIRBORNE_ERROR", exception.reason, nil);
}
}

RCT_EXPORT_METHOD(checkForUpdate:(NSString *)nameSpace
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject) {
@try {
[[Airborne sharedInstanceWithNamespace:nameSpace] checkForUpdate:^(NSString * _Nonnull result) {
resolve(result);
}];
} @catch (NSException *exception) {
reject(@"AIRBORNE_ERROR", exception.reason, nil);
}
}
#endif

#ifdef RCT_NEW_ARCH_ENABLED
Expand Down
1 change: 1 addition & 0 deletions airborne-react-native/src/NativeAirborne.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface Spec extends TurboModule {
readReleaseConfig(nameSpace: string): Promise<string>;
getFileContent(nameSpace: string, filePath: string): Promise<string>;
getBundlePath(nameSpace: string): Promise<string>;
checkForUpdate(nameSpace: string): Promise<string>;
}

export default TurboModuleRegistry.getEnforcing<Spec>('Airborne');
4 changes: 4 additions & 0 deletions airborne-react-native/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,8 @@ export function getBundlePath(nameSpace: string): Promise<string> {
return Airborne.getBundlePath(nameSpace);
}

export function checkForUpdate(nameSpace: string): Promise<string> {
return Airborne.checkForUpdate(nameSpace);
}

export default Airborne;
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import `in`.juspay.airborne.constants.LogSubCategory
import org.json.JSONArray
import org.json.JSONObject
import java.lang.ref.WeakReference
import java.net.HttpURLConnection.HTTP_OK
import java.util.concurrent.Callable
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentMap
Expand Down Expand Up @@ -423,6 +424,77 @@ class ApplicationManager(
return releaseConfig?.serialize() ?: ""
}

fun checkForUpdate(): String {
val url = if (releaseConfigTemplateUrl == "") {
rcCallback?.getReleaseConfig(false) ?: ""
} else {
releaseConfigTemplateUrl
}

if (url.isEmpty()) {
return JSONObject()
.put("updateAvailable", false)
.put("error", "No release config URL")
.toString()
}

val localRc = releaseConfig
val headers = mutableMapOf("cache-control" to "no-cache")
localRc?.let {
headers["x-release-config-version"] = it.version
headers["x-package-version"] = it.pkg.version
headers["x-config-version"] = it.config.version
}

val sortedDimensions = (rcHeaders ?: emptyMap()).toSortedMap()
if (sortedDimensions.isNotEmpty()) {
headers["x-dimension"] =
sortedDimensions.entries.joinToString(";") { "${it.key}=${it.value}" }
}

val clientId =
sanitizeClientId(otaServices.clientId?.takeIf { it.isNotBlank() } ?: "default")
val releaseConfigNetUtils = OTANetUtils(ctx, clientId, otaServices.cleanUpValue)

return try {
val resp = releaseConfigNetUtils.doGet(url, headers, null, null, null)
val code = resp.code()
val body = resp.body()

if (code != HTTP_OK || body == null) {
resp.close()
JSONObject()
.put("updateAvailable", false)
.put("error", "HTTP $code")
.toString()
} else {
val remoteJson = JSONObject(body.string())
resp.close()

val localVersion = localRc?.config?.version ?: ""
val localPkgVersion = localRc?.pkg?.version ?: ""
val remoteVersion =
remoteJson.optJSONObject("config")?.optString("version", "") ?: ""
val remotePkgVersion =
remoteJson.optJSONObject("package")?.optString("version", "") ?: ""

JSONObject()
.put("updateAvailable", remoteVersion != localVersion)
.put("currentVersion", localVersion)
.put("remoteVersion", remoteVersion)
.put("currentPackageVersion", localPkgVersion)
.put("remotePackageVersion", remotePkgVersion)
.toString()
}
} catch (e: Exception) {
Log.e(TAG, "checkForUpdate failed", e)
JSONObject()
.put("updateAvailable", false)
.put("error", e.message ?: "Unknown error")
.toString()
}
}

private fun trackUpdateResult(updateResult: UpdateResult) {
val result = when (updateResult) {
is UpdateResult.Ok -> "OK"
Expand Down
Loading
Loading