Skip to content
Merged
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
1 change: 1 addition & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.metarouter.reactnative">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need this in the sdk now since we are accessing network state, this gets merged with the consuming app's manifest

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.metarouter.reactnative

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

native kotlin monitor


import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.content.Context
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.Arguments
import com.facebook.react.modules.core.DeviceEventManagerModule

class MetaRouterNetworkMonitorModule(
private val reactContext: ReactApplicationContext
) : ReactContextBaseJavaModule(reactContext) {

override fun getName(): String = "MetaRouterNetworkMonitor"

@Volatile

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensure that reads + writes go to main memory, ensures that if two threads are on diff cores we aren't reading stale cached copy. Helps to ensure we don't run into subtle caching issues.

private var isConnected: Boolean = true

private var connectivityManager: ConnectivityManager? = null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow null for graceful degradation if not avail

private var networkCallback: ConnectivityManager.NetworkCallback? = null

init {
connectivityManager = try {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

grab reference to connectivity service

reactContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
} catch (_: Exception) {
null

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log here for debugging or intentionally swallowing exception when setting connectivityManager to null?

}

// Snapshot initial state
connectivityManager?.let { cm ->

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this .let means skip entire block if null

isConnected = try {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sets initial state

val network = cm.activeNetwork
val caps = network?.let { cm.getNetworkCapabilities(it) }
caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
} catch (_: Exception) {
true // fallback

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, wasn't sure if you wanted to log the exception at lest.

}
}

// Register callback for changes
connectivityManager?.let { cm ->
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()

val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
if (!isConnected) {
isConnected = true
sendEvent(true)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this sendEvent is sending the event across the react native bridge 💪 🌉 .

}
}

override fun onLost(network: Network) {
val activeNet = cm.activeNetwork

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a bit smarter bc if wifi drops we can't just respond to the onLost event we need to check if cellular connection still exists via the activeNet

val caps = activeNet?.let { cm.getNetworkCapabilities(it) }
val hasInternet = caps?.hasCapability(
NetworkCapabilities.NET_CAPABILITY_INTERNET
) == true
if (!hasInternet && isConnected) {
isConnected = false
sendEvent(false)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

send false, no longer connected

}
}
}

try {
cm.registerNetworkCallback(request, cb)
networkCallback = cb

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

register with the service then save callback for cleanup

} catch (_: SecurityException) {
// Missing ACCESS_NETWORK_STATE — fallback to always connected

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log?

}
}
}

private fun sendEvent(connected: Boolean) {
val params = Arguments.createMap().apply {
putBoolean("isConnected", connected)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defining shape of JS bridge event {isConnected: boolean}

}
try {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit("onConnectivityChange", params)
} catch (_: Exception) {
// JS runtime not ready yet — ignore

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log?

}
}

@ReactMethod

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react methods we expose

fun getCurrentStatus(promise: Promise) {
promise.resolve(isConnected)
}

@ReactMethod

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need to have these events otherwise there are warnings, no-op

fun addListener(@Suppress("UNUSED_PARAMETER") eventName: String) {
// Required for RN event emitter
}

@ReactMethod
fun removeListeners(@Suppress("UNUSED_PARAMETER") count: Int) {
// Required for RN event emitter
}

override fun invalidate() {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unregister callback on termination

networkCallback?.let { cb ->
try {
connectivityManager?.unregisterNetworkCallback(cb)
} catch (_: Exception) {
// ignore
}
}
networkCallback = null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager

class MetaRouterQueueStoragePackage : ReactPackage {
class MetaRouterPackage : ReactPackage {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

renaming bc now the native modules include more than just queue

override fun createNativeModules(
reactContext: ReactApplicationContext
): List<NativeModule> {
return listOf(MetaRouterQueueStorageModule(reactContext))
return listOf(
MetaRouterQueueStorageModule(reactContext),
MetaRouterNetworkMonitorModule(reactContext)
)
}

override fun createViewManagers(
Expand Down
76 changes: 76 additions & 0 deletions ios/MetaRouterNetworkMonitor.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#import <React/RCTBridgeModule.h>
#import <React/RCTEventEmitter.h>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

objective c implentation

#import <Network/Network.h>

@interface MetaRouterNetworkMonitor : RCTEventEmitter <RCTBridgeModule>
@end

@implementation MetaRouterNetworkMonitor {
nw_path_monitor_t _monitor;
dispatch_queue_t _monitorQueue;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for iOS we need to set up this queue so _isConnected is only ever read or written from one thread on a time. Objective c concurrency pattern

BOOL _isConnected;
BOOL _hasListeners;
}

RCT_EXPORT_MODULE()

+ (BOOL)requiresMainQueueSetup {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't initialize or block main thread while setting up

return NO;
}

- (instancetype)init {
self = [super init];
if (self) {
_isConnected = YES; // optimistic default
_hasListeners = NO;
_monitorQueue = dispatch_queue_create("com.metarouter.network.monitor", DISPATCH_QUEUE_SERIAL);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_monitor = nw_path_monitor_create();

__weak typeof(self) weakSelf = self;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

using weak reference here so that this can be properly deallocated

nw_path_monitor_set_update_handler(_monitor, ^(nw_path_t path) {
__strong typeof(weakSelf) strongSelf = weakSelf;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this is nil (so reference was deallocated) then we go ahead and return.

if (!strongSelf) return;

BOOL connected = (nw_path_get_status(path) == nw_path_status_satisfied);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nw path gives us overall status. not wifi vs. cellular

BOOL changed = (connected != strongSelf->_isConnected);
strongSelf->_isConnected = connected;

if (changed && strongSelf->_hasListeners) {
[strongSelf sendEventWithName:@"onConnectivityChange"
body:@{@"isConnected": @(connected)}];
}
});

nw_path_monitor_set_queue(_monitor, _monitorQueue);
nw_path_monitor_start(_monitor);
}
return self;
}

- (NSArray<NSString *> *)supportedEvents {
return @[@"onConnectivityChange"];
}

- (void)startObserving {
_hasListeners = YES;
}

- (void)stopObserving {
_hasListeners = NO;
}

RCT_EXPORT_METHOD(getCurrentStatus:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
{
dispatch_async(_monitorQueue, ^{
resolve(@(self->_isConnected));
});
}

- (void)dealloc {
if (_monitor) {
nw_path_monitor_cancel(_monitor);
}
}

@end
4 changes: 2 additions & 2 deletions react-native.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ module.exports = {
android: {
sourceDir: './android',
packageImportPath:
'import com.metarouter.reactnative.MetaRouterQueueStoragePackage;',
packageInstance: 'new MetaRouterQueueStoragePackage()',
'import com.metarouter.reactnative.MetaRouterPackage;',
packageInstance: 'new MetaRouterPackage()',
},
},
},
Expand Down
82 changes: 82 additions & 0 deletions src/analytics/MetaRouterAnalyticsClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MetaRouterAnalyticsClient } from './MetaRouterAnalyticsClient';
import type { InitOptions } from './types';
import { AppState } from 'react-native';
import { StubNetworkMonitor } from './utils/networkMonitor';

const mockAddEventListener = jest.fn();
jest
Expand Down Expand Up @@ -594,6 +595,87 @@ describe('MetaRouterAnalyticsClient', () => {
expect(client.queue[0].context.device.advertisingId).toBeUndefined();
});

describe('network awareness', () => {
it('getDebugInfo includes networkStatus', async () => {
const monitor = new StubNetworkMonitor('connected');
const client = new MetaRouterAnalyticsClient(opts, {
networkMonitor: monitor,
});
await client.init();

const debugInfo = await client.getDebugInfo();
expect(debugInfo.networkStatus).toBe('connected');
});

it('getDebugInfo reflects disconnected status', async () => {
const monitor = new StubNetworkMonitor('disconnected');
const client = new MetaRouterAnalyticsClient(opts, {
networkMonitor: monitor,
});
await client.init();

const debugInfo = await client.getDebugInfo();
expect(debugInfo.networkStatus).toBe('disconnected');
});

it('SDK functions normally when network monitoring unavailable', async () => {
// Don't inject networkMonitor — constructor will try to create
// a real NetworkMonitor which will fail in test env and fallback
// to always-connected
const client = new MetaRouterAnalyticsClient(opts);
await client.init();

client.track('Test Event');
expect(client.queue).toHaveLength(1);

await new Promise((resolve) => setTimeout(resolve, 10));
await client.flush();
expect(fetch).toHaveBeenCalled();
});

it('offline -> online transition resets circuit breaker and triggers flush', async () => {
const monitor = new StubNetworkMonitor('connected');
const client = new MetaRouterAnalyticsClient(opts, {
networkMonitor: monitor,
});
await client.init();

// Go offline
monitor.simulate('disconnected');

// Track events while offline
client.track('Offline Event 1');
client.track('Offline Event 2');

// Flush should not send (offline)
await client.flush();
// Events should still be in queue since network is unavailable
// (the dispatcher guard prevents HTTP calls)

// Go online — should trigger flush
(global as any).fetch = jest
.fn()
.mockResolvedValue({ ok: true, status: 200 });
monitor.simulate('connected');

// Allow the async flush to complete
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetch).toHaveBeenCalled();
});

it('reset() cleans up network monitor', async () => {
const monitor = new StubNetworkMonitor('connected');
const stopSpy = jest.spyOn(monitor, 'stop');
const client = new MetaRouterAnalyticsClient(opts, {
networkMonitor: monitor,
});
await client.init();

await client.reset();
expect(stopSpy).toHaveBeenCalled();
});
});

describe('tracing', () => {
it('does not include Trace header by default', async () => {
const client = new MetaRouterAnalyticsClient(opts);
Expand Down
Loading
Loading