From 10c1d3e75af44f1f5b473c0dba552363e13cfe49 Mon Sep 17 00:00:00 2001 From: Jaime Bernardo Date: Tue, 17 Jul 2018 15:04:49 +0100 Subject: [PATCH] plugin: add app channel for pause-resume events Adds an app channel for sending 'pause' and 'resume' events to node. On iOS, wait for the pause event handler to finish before letting the iOS application suspend after going to the background, by use of a pauseLock object passed to the pause event handlers. --- android/src/main/cpp/native-lib.cpp | 2 +- .../RNNodeJsMobileModule.java | 43 ++++- .../builtin_modules/rn-bridge/index.js | 71 ++++++++ ios/NodeRunner.mm | 165 ++++++++++++++++-- ios/RNNodeJsMobile.m | 4 +- 5 files changed, 265 insertions(+), 20 deletions(-) diff --git a/android/src/main/cpp/native-lib.cpp b/android/src/main/cpp/native-lib.cpp index 11aa4ec..d4e8ecf 100644 --- a/android/src/main/cpp/native-lib.cpp +++ b/android/src/main/cpp/native-lib.cpp @@ -58,7 +58,7 @@ void rcv_message(const char* channel_name, const char* msg) { if(!env) return; jclass cls2 = env->FindClass("com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule"); // try to find the class if(cls2 != nullptr) { - jmethodID m_sendMessage = env->GetStaticMethodID(cls2, "sendMessageBackToReact", "(Ljava/lang/String;Ljava/lang/String;)V"); // find method + jmethodID m_sendMessage = env->GetStaticMethodID(cls2, "sendMessageToApplication", "(Ljava/lang/String;Ljava/lang/String;)V"); // find method if(m_sendMessage != nullptr) { jstring java_channel_name=env->NewStringUTF(channel_name); jstring java_msg=env->NewStringUTF(msg); diff --git a/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule.java b/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule.java index 4866a9c..4ec4b23 100644 --- a/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule.java +++ b/android/src/main/java/com/janeasystems/rn_nodejs_mobile/RNNodeJsMobileModule.java @@ -9,6 +9,7 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.LifecycleEventListener; import javax.annotation.Nullable; import android.util.Log; @@ -22,7 +23,7 @@ import java.util.*; import java.util.concurrent.Semaphore; -public class RNNodeJsMobileModule extends ReactContextBaseJavaModule { +public class RNNodeJsMobileModule extends ReactContextBaseJavaModule implements LifecycleEventListener { private final ReactApplicationContext reactContext; private static final String TAG = "NODEJS-RN"; @@ -32,6 +33,7 @@ public class RNNodeJsMobileModule extends ReactContextBaseJavaModule { private static final String SHARED_PREFS = "NODEJS_MOBILE_PREFS"; private static final String LAST_UPDATED_TIME = "NODEJS_MOBILE_APK_LastUpdateTime"; private static final String BUILTIN_NATIVE_ASSETS_PREFIX = "nodejs-native-assets-"; + private static final String SYSTEM_CHANNEL = "_SYSTEM_"; private static String trashDirPath; private static String filesDirPath; @@ -46,6 +48,9 @@ public class RNNodeJsMobileModule extends ReactContextBaseJavaModule { private static AssetManager assetManager; + // Flag to indicate if node is ready to receive app events. + private static boolean nodeIsReadyForAppEvents = false; + static { System.loadLibrary("nodejs-mobile-react-native-native-lib"); System.loadLibrary("node"); @@ -60,6 +65,7 @@ public class RNNodeJsMobileModule extends ReactContextBaseJavaModule { public RNNodeJsMobileModule(ReactApplicationContext reactContext) { super(reactContext); this.reactContext = reactContext; + reactContext.addLifecycleEventListener(this); filesDirPath = reactContext.getFilesDir().getAbsolutePath(); // The paths where we expect the node project assets to be at runtime. @@ -182,6 +188,41 @@ private void sendEvent(String eventName, .emit(eventName, params); } + 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 React Native. + sendMessageBackToReact(channelName, msg); + } + } + + @Override + public void onHostPause() { + if (nodeIsReadyForAppEvents) { + sendMessageToNodeChannel(SYSTEM_CHANNEL, "pause"); + } + } + + @Override + public void onHostResume() { + if (nodeIsReadyForAppEvents) { + sendMessageToNodeChannel(SYSTEM_CHANNEL, "resume"); + } + } + + @Override + public void onHostDestroy() { + // Activity `onDestroy` + } + + public static void handleAppChannelMessage(String msg) { + if (msg.equals("ready-for-app-events")) { + nodeIsReadyForAppEvents=true; + } + } + // Called from JNI when node sends a message through the bridge. public static void sendMessageBackToReact(String channelName, String msg) { if (_instance != null) { diff --git a/install/resources/nodejs-modules/builtin_modules/rn-bridge/index.js b/install/resources/nodejs-modules/builtin_modules/rn-bridge/index.js index 6bfb3e9..9c354eb 100644 --- a/install/resources/nodejs-modules/builtin_modules/rn-bridge/index.js +++ b/install/resources/nodejs-modules/builtin_modules/rn-bridge/index.js @@ -9,6 +9,12 @@ const NativeBridge = process.binding('rn_bridge'); */ const EVENT_CHANNEL = '_EVENTS_'; +/** + * Built-in, one-way event channel reserved for sending events from + * the react-native plug-in native layer to the Node.js app. + */ +const SYSTEM_CHANNEL = '_SYSTEM_'; + /** * This class is defined in the plugin's root index.js as well. * Any change made here should be ported to the root index.js too. @@ -87,6 +93,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. @@ -96,6 +130,34 @@ class SystemChannel extends ChannelSuper { super(name); }; + 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); @@ -130,6 +192,15 @@ function registerChannel(channel) { NativeBridge.registerChannel(channel.name, bridgeListener); }; +/** + * Module exports. + */ +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); diff --git a/ios/NodeRunner.mm b/ios/NodeRunner.mm index 7bd0e54..a9a0504 100644 --- a/ios/NodeRunner.mm +++ b/ios/NodeRunner.mm @@ -1,15 +1,9 @@ +#import #include "NodeRunner.hpp" #include #include #include "rn-bridge.h" -void rcv_message(const char* channelName, const char* msg) { - @autoreleasepool { - NSString* objectiveCChannelName=[NSString stringWithUTF8String:channelName]; - NSString* objectiveCMessage=[NSString stringWithUTF8String:msg]; - [[NodeRunner sharedInstance] sendMessageBackToReact:objectiveCChannelName:objectiveCMessage]; - } -} @implementation NodeRunner { @@ -18,6 +12,23 @@ @implementation NodeRunner @synthesize startedNodeAlready = _startedNodeAlready; +NSString* const SYSTEM_CHANNEL = @"_SYSTEM_"; + +void rcv_message(const char* channelName, const char* msg) { + @autoreleasepool { + NSString* objectiveCChannelName=[NSString stringWithUTF8String:channelName]; + NSString* objectiveCMessage=[NSString stringWithUTF8String:msg]; + + if ([objectiveCChannelName isEqualToString:SYSTEM_CHANNEL]) { + // If it's a system channel call, handle it in the plugin native side. + handleAppChannelMessage(objectiveCMessage); + } else { + // Otherwise, send it to React Native. + [[NodeRunner sharedInstance] sendMessageBackToReact:objectiveCChannelName:objectiveCMessage]; + } + } +} + + (NodeRunner*)sharedInstance { static NodeRunner *_instance = nil; @synchronized(self) { @@ -31,12 +42,134 @@ - (id)init { _currentModuleInstance=nil; _startedNodeAlready=false; } + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onPause) + name:UIApplicationDidEnterBackgroundNotification object:nil]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(onResume) + name:UIApplicationWillEnterForegroundNotification object:nil]; return self; } - (void)dealloc { } +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. + [[NodeRunner sharedInstance] ReleasePauseEvent:eventArguments[1]]; + } + } else if ([msg isEqualToString:@"ready-for-app-events"]) { + // The nodejs runtime is ready for APP events. + nodeIsReadyForAppEvents = true; + } +} + +// 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 + * - onResume + */ + +- (void) onPause { + 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 { + if(nodeIsReadyForAppEvents) { + [[NodeRunner sharedInstance] sendMessageToNode: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]; + } + + [[NodeRunner sharedInstance] sendMessageToNode:SYSTEM_CHANNEL:event]; + + 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]; +} + + - (void) setCurrentRNNodeJsMobile:(RNNodeJsMobile*)module { _currentModuleInstance=module; @@ -71,38 +204,38 @@ - (void) startEngineWithArguments:(NSArray*)arguments:(NSString*)builtinModulesP setenv([@"NODE_PATH" UTF8String], (const char*)[nodePath UTF8String], 1); int c_arguments_size=0; - + //Compute byte size need for all arguments in contiguous memory. for (id argElement in arguments) { c_arguments_size+=strlen([argElement UTF8String]); c_arguments_size++; // for '\0' } - + //Stores arguments in contiguous memory. char* args_buffer=(char*)calloc(c_arguments_size, sizeof(char)); - + //argv to pass into node. char* argv[[arguments count]]; - + //To iterate through the expected start position of each argument in args_buffer. char* current_args_position=args_buffer; - + //Argc int argument_count=0; - + //Populate the args_buffer and argv. for (id argElement in arguments) { const char* current_argument=[argElement UTF8String]; - + //Copy current argument to its expected position in args_buffer strncpy(current_args_position, current_argument, strlen(current_argument)); - + //Save current argument start position in argv and increment argc. argv[argument_count]=current_args_position; argument_count++; - + //Increment to the next argument's expected position. current_args_position+=strlen(current_args_position)+1; } diff --git a/ios/RNNodeJsMobile.m b/ios/RNNodeJsMobile.m index 01e2737..fb7c694 100644 --- a/ios/RNNodeJsMobile.m +++ b/ios/RNNodeJsMobile.m @@ -20,7 +20,7 @@ - (dispatch_queue_t)methodQueue + (BOOL)requiresMainQueueSetup { - return NO; + return YES; } - (id)init @@ -30,7 +30,7 @@ - (id)init { [[NodeRunner sharedInstance] setCurrentRNNodeJsMobile:self]; } - + NSString* builtinModulesPath = [[NSBundle mainBundle] pathForResource:BUILTIN_MODULES_RESOURCE_PATH ofType:@""]; nodePath = [[NSBundle mainBundle] pathForResource:NODEJS_PROJECT_RESOURCE_PATH ofType:@""]; nodePath = [nodePath stringByAppendingString:@":"];