Im using React-Native and I have a saga that manages the state of a websocket. It implements re-tries and receives dispatches for things like location change to send it up to the server via websocket. I am using @mauron85/react-native-background-geolocation
to grab location events in the root component of my app and I am dispatching them to redux. The route saga has a spawned generator that listens for this and will send it up. When the app is in foreground(currently in use) the socket can recover from network loss and continue receiving location events however when the app goes to the background as long as the socket is open, the saga responds to the events and can still send them up. As soon as I mark airplane mode and lose the socket, the saga stops responding to retries and other dispatch events such as the location update. When i remove airplane mode (while still in the background) it is not able to re-establish connection and receive dispatches. How can I approach this to be able to allow the app to recover without it ever transitioning back to the foreground.
Saga Code:
function* sender(socket: WebSocket, eventType, data?) {
const { token } = yield select(authState);
if (socket && socket.readyState === WebSocket.OPEN) {
let localData = {};
if (data) {
localData = data;
}
console.log(JSON.stringify({ type: eventType, data: { token, ...localData } }));
socket.send(JSON.stringify({ type: eventType, data: { token, ...localData } }));
if (eventType === ROUTE_SOCKET_MESSSAGE_TYPES.PING) {
const res = yield race({
success: take(ROUTE_SOCKET_MESSAGE_RECEIVED),
timeout: delay(SOCKET_PING_RESPONSE_EXPECTED)
});
if (res.timeout) {
console.log('ENCOUNTERED LAG IN MESSAGE RECEIPT');
yield put(markNetworkLost());
yield put({ type: ROUTE_SOCKET_RETRY_ACTION_TYPE });
} else {
yield put(markHasNetwork());
}
}
}
}
function* pinger(socket) {
while (true) {
yield call(sender, socket, ROUTE_SOCKET_MESSSAGE_TYPES.PING);
yield delay(FORTY_FIVE_SECONDS);
}
}
function* sendPosition(socket) {
while (true) {
const { latitude, longitude } = yield select(locationState);
if (latitude && longitude) {
yield call(positionDispatcher, socket, {
lat: latitude,
lng: longitude
});
}
yield take('locations/ON_LOCATION_CHANGE');
}
}
function* locationApiRequest() {
try {
const res = yield call(getLocation);
yield put(onPharmacyDetailsReceived(res.data));
} catch (error) {
console.log('Could not fetch pharmacy location details', error);
}
}
function* positionDispatcher(socket, position) {
yield call(sender, socket, ROUTE_SOCKET_MESSSAGE_TYPES.DRIVER_LOCATION, position);
}
function dispatchPhotoFetch(route) {
route.routeStops.forEach((stop) => {
if (stop.address.photos) {
console.log(stop.address.photos);
PhotoService.readLocalFileOrDownload(
stop.address.id,
stop.address.photos.map((photo) => new AddressPhoto(photo))
);
}
});
}
function* socketChannelListener(socketChannel, socket) {
let pingerProcess;
let positionProcess;
while (true) {
const payload = yield take(socketChannel); // take incoming message
const { type: baseSocketResponseType } = payload;
switch (baseSocketResponseType) {
case 'open':
yield fork(locationApiRequest);
pingerProcess = yield fork(pinger, socket);
positionProcess = yield fork(sendPosition, socket);
PendingTasks.activate(); // activate pending task upload when the socket opens
break;
case 'error':
console.log('ERROR', payload.reason, payload);
break;
case 'close':
console.log('CLOSE', payload.reason, payload);
if (payload.reason === 'Invalid creds') {
// console.log('ENCOUNTERED INVALID CREDENTIALS/STALE TOKEN');
yield put(autoLogout());
} else {
yield put(markNetworkLost());
yield put({ type: ROUTE_SOCKET_RETRY_ACTION_TYPE });
}
// console.log('CALLING CLOSE');
yield cancel(pingerProcess);
yield cancel(positionProcess);
return;
default:
break;
}
let type;
let data;
let operation;
let parsedData;
if (payload && payload.data) {
parsedData = JSON.parse(payload.data);
const { type: incomingType, data: incomingData, op } = parsedData;
type = incomingType;
data = incomingData;
operation = op;
}
// console.log('PARSED MESSAGE', parsedData);
switch (type) {
case ROUTE_SOCKET_TYPES.AUTH_STATUS:
switch (data) {
case PINGER_RESPONSE_TYPES.AUTH_SUCCESS:
yield put({ type: ROUTE_SOCKET_MESSAGE_RECEIVED });
break;
case PINGER_RESPONSE_TYPES.TOKEN_EXPIRED:
break;
default:
break;
}
break;
case ROUTE_SOCKET_TYPES.DRIVER_ROUTE:
console.log(
`received DRIVER_ROUTE with ${JSON.stringify(data.routeStops.length)} stop(s)`,
operation,
data
);
switch (operation) {
case DRIVER_ROUTE_OPERATIONS.I: {
const currentRoute = yield select(driverRouteState);
// if we do pick up a route..
if (!currentRoute || currentRoute.id !== data.id) {
yield fork(dispatchPhotoFetch, data);
yield put(onDriverRouteChange(data));
}
break;
}
case DRIVER_ROUTE_OPERATIONS.U:
// i believe we will ignore this if there are records in sqlite?
// create address photo objects first?
// yield put(onDriverRouteUpdate(data));
break;
case DRIVER_ROUTE_OPERATIONS.D:
// TODO: deletion of a route needs to be handled most likely.
break;
default:
break;
}
break;
case ROUTE_SOCKET_TYPES.DRIVER_ROUTE_LOG_REQUEST:
break;
default:
break;
}
}
}
function createSocketChannel(token) {
// TODO: we need to pull this from config....
const socket = new WebSocket(`${ROUTE_SOCKET_URL}${token}`);
// establishes a redux emitter that saga can wait for (like an action)
return {
socket,
socketChannel: eventChannel((emit) => {
socket.onmessage = (event) => {
// console.log('--MESSAGE received--', event);
emit(event);
};
socket.onopen = (evt) => {
emit(evt);
};
socket.onclose = (evt) => {
emit(evt);
};
socket.onerror = (evt) => {
emit(evt);
};
const unsubscribe = () => {
socket.onmessage = null;
};
return unsubscribe;
})
};
}
function* routeSocket(token): any {
const { socketChannel, socket } = yield call(createSocketChannel, token);
yield fork(socketChannelListener, socketChannel, socket);
}
// this method will be retried
export function* RouteSocketSaga(): any {
let task;
while (true) {
const routeSocketAction = yield take([
'DB_INITIALIZED',
PERSIST_REHYDRATE,
ON_AUTH_CHANGE,
ROUTE_SOCKET_RETRY_ACTION_TYPE // retry will attempt to start the saga again..
]);
console.log(routeSocketAction);
if (routeSocketAction.type === PERSIST_REHYDRATE) {
const currentRoute = yield select(driverRouteState);
if (currentRoute) {
yield fork(dispatchPhotoFetch, currentRoute);
}
}
// if the action is to retry, we will wait 5 seconds..
if (routeSocketAction.type === ROUTE_SOCKET_RETRY_ACTION_TYPE) {
yield delay(WAIT_BEFORE_RETRY_TIME);
}
const { token } = yield select(authState);
if (token) {
if (task) {
yield cancel(task);
}
task = yield fork(routeSocket, token);
}
}
}
Component code sending the location
BackgroundGeolocation.configure({
desiredAccuracy: BackgroundGeolocation.HIGH_ACCURACY,
stationaryRadius: 1,
distanceFilter: 1,
notificationTitle: 'Background tracking',
debug: false,
startOnBoot: false,
url: null,
stopOnTerminate: true,
locationProvider: BackgroundGeolocation.ACTIVITY_PROVIDER,
interval: 1000,
fastestInterval: 1000,
activitiesInterval: 1000,
startForeground: false,
stopOnStillActivity: false
});
BackgroundGeolocation.on('background', () => {
console.log('[INFO] App is in background');
setAppState(true);
});
BackgroundGeolocation.on('foreground', () => {
console.log('[INFO] App is in foreground');
setAppState(false);
});
BackgroundGeolocation.on('location', (location) => {
dispatch(onLocationChange({ latitude: location.latitude, longitude: location.longitude }));
console.log('LOCATION', location);
});
BackgroundGeolocation.on('stationary', (stationaryLocation) => {
console.log('STATIONARY LOCATION', stationaryLocation);
});
Ended up moving socket logic to the root component of my app and responding to location updates there which allowed me to keep the socket alive in the background and send location updates. E.g
locationSendingEventHandler = (location) => {
const {
coords: { latitude, longitude }
} = location;
console.log('LOCATION', location);
const { token } = this.props;
let socketReopenTriggered = false;
if ((!this.socket || (this.socket && this.socket.readyState >= 2)) && token) {
// 2: CLOSING 3: CLOSED ... two cases where location event should trigger socket to re-open and there is a token
socketReopenTriggered = true;
if (this.setupSocketTimeout) {
clearTimeout(this.setupSocketTimeout);
this.setupSocketTimeout = null;
}
this.setUpSocket('from location');
}
if (!socketReopenTriggered && this.socket && this.socket.readyState === 1) {
console.log('SENDING LOCATION', location, this.socket);
this.socket.send(
JSON.stringify({
type: 'DRIVER_LOCATION',
data: { token, ...{ lat: latitude, lng: longitude } }
})
);
}
};