Skip to content

Commit

Permalink
ios: wait for pause event handler on background
Browse files Browse the repository at this point in the history
Waits for the pause event handler to finish before letting the iOS
application suspend after going to the background.
Passes a pauseLock object to the pause event handlers to release so
the app channel can signal the application it can suspend.
  • Loading branch information
jaimecbernardo committed May 7, 2018
1 parent 775839e commit 0eab389
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 14 deletions.
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,14 +162,28 @@ Registers callbacks for App events.
Currently supports the 'pause' and 'resume' events, which are raised automatically when the app switches to the background/foreground.
```js
cordova.app.on('pause', () => {
cordova.app.on('pause', (pauseLock) => {
console.log('[node] app paused.');
pauseLock.release();
});
cordova.app.on('resume', () => {
console.log('[node] app resumed.');
});
```
The 'pause' event is raised when the application switches to the background. On iOS, the system will wait for the 'pause' event handlers to return before finally suspending the application. For the purpose of letting the iOS application know when it can safely suspend after going to the background, a `pauseLock` argument is passed to each 'pause' listener, so that `release()` can be called on it to signal that listener has finished doing all the work it needed to do. The application will only suspend after all the locks have been released (or iOS forces it to).

```js
cordova.app.on('pause', (pauseLock) => {
server.close( () => {
// App will only suspend after the server stops listening for connections and current connections are closed.
pauseLock.release();
});
});
```

**Warning :** On iOS, the application will eventually be suspended, so the pause event should be used to run the clean up operations as quickly as possible and let the application suspend after that. Make sure to call `pauseLock.release()` in each 'pause' event listener, or your Application will keep running in the background for as long as iOS will allow it.

### cordova.app.datadir()

Returns a writable path used for persistent data storage in the application. Its value corresponds to `NSDocumentDirectory` on iOS and `FilesDir` on Android.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,34 @@ class EventChannel extends ChannelSuper {
};
};

/**
* System event Lock class
* Helper class to handle lock acquisition and release in system event handlers.
* Will call a callback after every lock has been released.
**/
class SystemEventLock {
constructor(callback, startingLocks) {
this._locksAcquired = startingLocks; // Start with one lock.
this._callback = callback; // Callback to call after all locks are released.
this._hasReleased = false; // To stop doing anything after it's supposed to serve its purpose.
this._checkRelease(); // Initial check. If it's been started with no locks, can be released right away.
}
// Release a lock and call the callback if all locks have been released.
release() {
if (this._hasReleased) return;
this._locksAcquired--;
this._checkRelease();
}
// Check if the lock can be released and release it.
_checkRelease() {
if(this._locksAcquired<=0) {
this._hasReleased=true;
this._callback();
}
}

}

/**
* System channel class.
* Emit pause/resume events when the app goes to background/foreground.
Expand All @@ -105,6 +133,34 @@ class SystemChannel extends ChannelSuper {
this._cacheDataDir = null;
};

emitWrapper(type) {
// Overload the emitWrapper to handle the pause event locks.
const _this = this;
if (type.startsWith('pause')) {
setImmediate( () => {
let releaseMessage = 'release-pause-event';
let eventArguments = type.split('|');
if (eventArguments.length >= 2) {
// The expected format for the release message is "release-pause-event|{eventId}"
// eventId comes from the pause event, with the format "pause|{eventId}"
releaseMessage = releaseMessage + '|' + eventArguments[1];
}
// Create a lock to signal the native side after the app event has been handled.
let eventLock = new SystemEventLock(
() => {
NativeBridge.sendMessage(_this.name, releaseMessage);
}
, _this.listenerCount("pause") // A lock for each current event listener. All listeners need to call release().
);
_this.emitLocal("pause", eventLock);
});
} else {
setImmediate( () => {
_this.emitLocal(type);
});
}
};

processData(data) {
// The data is the event.
this.emitWrapper(data);
Expand Down Expand Up @@ -153,6 +209,9 @@ function registerChannel(channel) {
const systemChannel = new SystemChannel(SYSTEM_CHANNEL);
registerChannel(systemChannel);

// Signal we are ready for app events, so the native code won't lock before node is ready to handle those.
NativeBridge.sendMessage(SYSTEM_CHANNEL, "ready-for-app-events");

const eventChannel = new EventChannel(EVENT_CHANNEL);
registerChannel(eventChannel);

Expand Down
30 changes: 25 additions & 5 deletions src/android/java/com/janeasystems/cdvnodejsmobile/NodeJS.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,16 @@ public class NodeJS extends CordovaPlugin {
private static boolean initCompleted = false;
private static IOException ioe = null;

private static boolean appPaused = false;
private static String LOGTAG = "NODEJS-CORDOVA";
private static String SYSTEM_CHANNEL = "_SYSTEM_";

private static boolean engineAlreadyStarted = false;

private static CallbackContext allChannelListenerContext = null;

// Flag to indicate if node is ready to receive app events.
private static boolean nodeIsReadyForAppEvents = false;

static {
System.loadLibrary("nodejs-mobile-cordova-native-lib");
System.loadLibrary("node");
Expand Down Expand Up @@ -155,23 +157,35 @@ public boolean execute(String action, JSONArray data, CallbackContext callbackCo
public void onPause(boolean multitasking) {
super.onPause(multitasking);
Log.d(LOGTAG, "onPause");
sendMessageToNodeChannel(SYSTEM_CHANNEL, "pause");
appPaused = true;
if (nodeIsReadyForAppEvents) {
sendMessageToNodeChannel(SYSTEM_CHANNEL, "pause");
}
}

@Override
public void onResume(boolean multitasking) {
super.onResume(multitasking);
Log.d(LOGTAG, "onResume");
sendMessageToNodeChannel(SYSTEM_CHANNEL, "resume");
appPaused = false;
if (nodeIsReadyForAppEvents) {
sendMessageToNodeChannel(SYSTEM_CHANNEL, "resume");
}
}

private boolean sendMessageToNode(String channelName, String msg) {
sendMessageToNodeChannel(channelName, msg);
return true;
}

public static void sendMessageToApplication(String channelName, String msg) {
if (channelName.equals(SYSTEM_CHANNEL)) {
// If it's a system channel call, handle it in the plugin native side.
handleAppChannelMessage(msg);
} else {
// Otherwise, send it to Cordova.
sendMessageToCordova(channelName, msg);
}
}

public static void sendMessageToCordova(String channelName, String msg) {
final String channel = new String(channelName);
final String message = new String(msg);
Expand All @@ -188,6 +202,12 @@ public void run() {
});
}

public static void handleAppChannelMessage(String msg) {
if (msg.equals("ready-for-app-events")) {
nodeIsReadyForAppEvents=true;
}
}

private boolean setAllChannelsListener(final CallbackContext callbackContext) {
Log.v(LOGTAG, "setAllChannelsListener");
NodeJS.allChannelListenerContext = callbackContext;
Expand Down
2 changes: 1 addition & 1 deletion src/android/jni/native-lib.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ void rcv_message_from_node(const char* channel_name, const char* msg) {
if (cls2 != nullptr) {
// Find the method
jmethodID m_sendMessage = env->GetStaticMethodID(cls2,
"sendMessageToCordova",
"sendMessageToApplication",
"(Ljava/lang/String;Ljava/lang/String;)V");
if (m_sendMessage != nullptr) {
jstring java_channel_name=env->NewStringUTF(channel_name);
Expand Down
129 changes: 122 additions & 7 deletions src/ios/CDVNodeJS.mm
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,46 @@ @implementation CDVNodeJS
/**
* A method that can be called from the C++ Node native module (i.e. cordova-bridge.ccp).
*/
void sendMessageToCordova(const char* channelName, const char* msg) {
void sendMessageToApplication(const char* channelName, const char* msg) {

NSString* channelNameNS = [NSString stringWithUTF8String:channelName];
NSString* msgNS = [NSString stringWithUTF8String:msg];

if ([channelNameNS isEqualToString:[NSString stringWithUTF8String:SYSTEM_CHANNEL]]) {
// If it's a system channel call, handle it in the plugin native side.
handleAppChannelMessage(msgNS);
} else {
// Otherwise, send it to Cordova.
sendMessageToCordova(channelNameNS,msgNS);
}

}

void sendMessageToCordova(NSString* channelName, NSString* msg) {
NSMutableArray* arguments = [NSMutableArray array];
[arguments addObject: [NSString stringWithUTF8String:channelName]];
[arguments addObject: [NSString stringWithUTF8String:msg]];
[arguments addObject: channelName];
[arguments addObject: msg];

CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray:arguments];
[pluginResult setKeepCallbackAsBool:TRUE];
[activeInstance.commandDelegate sendPluginResult:pluginResult callbackId:activeInstance.allChannelsListenerCallbackId];
}

void handleAppChannelMessage(NSString* msg) {
if([msg hasPrefix:@"release-pause-event"]) {
// The nodejs runtime has signaled it has finished handling a pause event.
NSArray *eventArguments = [msg componentsSeparatedByString:@"|"];
// The expected format for this message is "release-pause-event|{eventId}"
if (eventArguments.count >=2) {
// Release the received eventId.
[activeInstance ReleasePauseEvent:eventArguments[1]];
}
} else if ([msg isEqualToString:@"ready-for-app-events"]) {
// The nodejs runtime is ready for APP events.
nodeIsReadyForAppEvents = true;
}
}

// The callback id of the Cordova channel listener
NSString* allChannelsListenerCallbackId = nil;

Expand All @@ -59,8 +89,8 @@ - (void) pluginInitialize {
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(onResume)
name:UIApplicationWillEnterForegroundNotification object:nil];
RegisterBridgeCallback(sendMessageToCordova);

RegisterBridgeCallback(sendMessageToApplication);

// Register the Documents Directory as the node dataDir.
NSString* nodeDataDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
Expand Down Expand Up @@ -104,6 +134,18 @@ - (void) onReset {
LOG_FN
}

// Flag to indicate if node is ready to receive app events.
bool nodeIsReadyForAppEvents = false;

// Condition to wait on pause event handling on the node side.
NSCondition *appEventBeingProcessedCondition = [[NSCondition alloc] init];

// Set to keep ids for called pause events, so they can be unlocked later.
NSMutableSet* appPauseEventsManagerSet = [[NSMutableSet alloc] init];

// Lock to manipulate the App Pause Events Manager Set.
id appPauseEventsManagerSetLock = [[NSObject alloc] init];

/**
* Handlers for events registered by the plugin:
* - onPause
Expand All @@ -112,12 +154,85 @@ - (void) onReset {

- (void) onPause {
LOG_FN
SendMessageToNodeChannel(SYSTEM_CHANNEL, "pause");
if(nodeIsReadyForAppEvents) {
UIApplication *application = [UIApplication sharedApplication];
// Inform the app intends do run something in the background.
// In this case we'll try to wait for the pause event to be properly taken care of by node.
__block UIBackgroundTaskIdentifier backgroundWaitForPauseHandlerTask =
[application beginBackgroundTaskWithExpirationHandler: ^ {
// Expiration handler to avoid app crashes if the task doesn't end in the iOS allowed background duration time.
[application endBackgroundTask: backgroundWaitForPauseHandlerTask];
backgroundWaitForPauseHandlerTask = UIBackgroundTaskInvalid;
}];

NSTimeInterval intendedMaxDuration = [application backgroundTimeRemaining]+1;
// Calls the event in a background thread, to let this UIApplicationDidEnterBackgroundNotification
// return as soon as possible.
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSDate * targetMaximumFinishTime = [[NSDate date] dateByAddingTimeInterval:intendedMaxDuration];
// We should block the thread at most until a bit (1 second) after the maximum allowed background time.
// The background task will be ended by the expiration handler, anyway.
// SendPauseEventAndWaitForRelease won't return until the node runtime notifies it has finished its pause event (or the target time is reached).
[self SendPauseEventAndWaitForRelease:targetMaximumFinishTime];
// After SendPauseEventToNodeChannel returns, clean up the background task and let the Application enter the suspended state.
[application endBackgroundTask: backgroundWaitForPauseHandlerTask];
backgroundWaitForPauseHandlerTask = UIBackgroundTaskInvalid;
});
}
}

- (void) onResume {
LOG_FN
SendMessageToNodeChannel(SYSTEM_CHANNEL, "resume");
if(nodeIsReadyForAppEvents) {
SendMessageToNodeChannel(SYSTEM_CHANNEL, "resume");
}
}

// Sends the pause event to the node runtime and returns only after node signals
// the event has been handled explicitely or the background time is running out.
- (void) SendPauseEventAndWaitForRelease:(NSDate*)expectedFinishTime {
// Get unique identifier for this pause event.
NSString * eventId = [[NSUUID UUID] UUIDString];
// Create the pause event message with the id.
NSString * event = [NSString stringWithFormat:@"pause|%@", eventId];

[appEventBeingProcessedCondition lock];

@synchronized(appPauseEventsManagerSetLock) {
[appPauseEventsManagerSet addObject:eventId];
}

SendMessageToNodeChannel(SYSTEM_CHANNEL, (const char*)[event UTF8String]);

while (YES) {
// Looping to avoid unintended spurious wake ups.
@synchronized(appPauseEventsManagerSetLock) {
if(![appPauseEventsManagerSet containsObject:eventId]) {
// The Id for this event has been released.
break;
}
}
if([expectedFinishTime timeIntervalSinceNow] <= 0) {
// We blocked the background thread long enough.
break;
}
[appEventBeingProcessedCondition waitUntilDate:expectedFinishTime];
}
[appEventBeingProcessedCondition unlock];

@synchronized(appPauseEventsManagerSetLock) {
[appPauseEventsManagerSet removeObject:eventId];
}
}

// Signals the pause event has been handled by the node side.
- (void) ReleasePauseEvent:(NSString*)eventId {
[appEventBeingProcessedCondition lock];
@synchronized(appPauseEventsManagerSetLock) {
[appPauseEventsManagerSet removeObject:eventId];
}
[appEventBeingProcessedCondition broadcast];
[appEventBeingProcessedCondition unlock];
}

/**
Expand Down

0 comments on commit 0eab389

Please sign in to comment.