diff --git a/.github/workflows/ios_unit_tests.yml b/.github/workflows/ios_unit_tests.yml new file mode 100644 index 000000000..9d7e3f26b --- /dev/null +++ b/.github/workflows/ios_unit_tests.yml @@ -0,0 +1,40 @@ +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + ios-unit-tests: + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + + - uses: futureware-tech/simulator-action@v5 + id: ios-simulator + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.29' + cache: true + + - name: Set up iOS test project + # --skip-tests sets up the CocoaPods workspace (downloads all deps, generates + # the ably_flutter-Unit-Tests scheme) without booting a simulator + run: | + cd ios + pod lib lint ably_flutter.podspec \ + --allow-warnings \ + --skip-tests \ + --validation-dir=/tmp/ably_test_build \ + --no-clean + + - name: Run iOS unit tests + timeout-minutes: 30 + run: | + xcodebuild test \ + -workspace /tmp/ably_test_build/App.xcworkspace \ + -scheme ably_flutter-Unit-Tests \ + -destination 'id=${{ steps.ios-simulator.outputs.udid }}' \ + CODE_SIGNING_ALLOWED=NO diff --git a/ios/Classes/AblyStreamsChannel.m b/ios/Classes/AblyStreamsChannel.m index ea349057d..4e647289d 100644 --- a/ios/Classes/AblyStreamsChannel.m +++ b/ios/Classes/AblyStreamsChannel.m @@ -66,26 +66,26 @@ - (void)setStreamHandlerFactory:(NSObject *(^)(id))factory [_messenger setMessageHandlerOnChannel:_name binaryMessageHandler:nil]; return; } - + _streams = [NSMutableDictionary new]; _listenerArguments = [NSMutableDictionary new]; FlutterBinaryMessageHandler messageHandler = ^(NSData* message, FlutterBinaryReply callback) { FlutterMethodCall* call = [self->_codec decodeMethodCall:message]; NSArray *methodParts = [call.method componentsSeparatedByString:@"#"]; - + if (methodParts.count != 2) { callback(nil); return; } - + NSInteger keyValue = [methodParts.lastObject integerValue]; if(keyValue == 0) { callback([self->_codec encodeErrorEnvelope:[FlutterError errorWithCode:@"error" message:[NSString stringWithFormat:@"Invalid method name: %@", call.method] details:nil]]); return; } - + NSNumber *key = [NSNumber numberWithInteger:keyValue]; - + if ([methodParts.firstObject isEqualToString:@"listen"]) { [self listenForCall:call withKey:key usingCallback:callback andFactory:factory]; } else if ([methodParts.firstObject isEqualToString:@"cancel"]) { @@ -94,7 +94,7 @@ - (void)setStreamHandlerFactory:(NSObject *(^)(id))factory callback(nil); } }; - + [_messenger setMessageHandlerOnChannel:_name binaryMessageHandler:messageHandler]; } @@ -110,22 +110,25 @@ - (void) reset{ - (void)listenForCall:(FlutterMethodCall*)call withKey:(NSNumber*)key usingCallback:(FlutterBinaryReply)callback andFactory:(NSObject *(^)(id))factory { AblyStreamsChannelStream *stream = [AblyStreamsChannelStream new]; stream.sink = ^(id event) { - NSString *name = [NSString stringWithFormat:@"%@#%@", self->_name, key]; - - if (event == FlutterEndOfEventStream) { - [self->_messenger sendOnChannel:name message:nil]; - } else if ([event isKindOfClass:[FlutterError class]]) { - [self->_messenger sendOnChannel:name - message:[self->_codec encodeErrorEnvelope:(FlutterError*)event]]; - } else { - [self->_messenger sendOnChannel:name message:[self->_codec encodeSuccessEnvelope:event]]; + @try { + NSString *name = [NSString stringWithFormat:@"%@#%@", self->_name, key]; + if (event == FlutterEndOfEventStream) { + [self->_messenger sendOnChannel:name message:nil]; + } else if ([event isKindOfClass:[FlutterError class]]) { + [self->_messenger sendOnChannel:name + message:[self->_codec encodeErrorEnvelope:(FlutterError*)event]]; + } else { + [self->_messenger sendOnChannel:name message:[self->_codec encodeSuccessEnvelope:event]]; + } + } @catch (NSException *exception) { + NSLog(@"AblyFlutter: Dropped event, engine not running: %@", exception.reason); } }; stream.handler = factory(call.arguments); - + [_streams setObject:stream forKey:key]; [_listenerArguments setObject:call.arguments forKey:key]; - + [self triggerCallback:callback error:[stream.handler onListenWithArguments:call.arguments eventSink:stream.sink]]; } @@ -136,10 +139,10 @@ - (void)cancelForCall:(FlutterMethodCall*)call withKey:(NSNumber*)key usingCallb callback([_codec encodeErrorEnvelope:[FlutterError errorWithCode:@"error" message:@"No active stream to cancel" details:nil]]); return; } - + [_streams removeObjectForKey:key]; [_listenerArguments removeObjectForKey:key]; - + [self triggerCallback:callback error:[stream.handler onCancelWithArguments:call.arguments]]; } diff --git a/ios/Tests/AblyStreamsChannelTests.m b/ios/Tests/AblyStreamsChannelTests.m new file mode 100644 index 000000000..7ef7d6d52 --- /dev/null +++ b/ios/Tests/AblyStreamsChannelTests.m @@ -0,0 +1,111 @@ +#import +#import +#import "AblyStreamsChannel.h" + +// Mock binary messenger: captures the binaryMessageHandler registered by AblyStreamsChannel, +// and optionally throws NSInternalInconsistencyException on sendOnChannel:message: to simulate +// a suspended/detached FlutterEngine. +@interface MockThrowingMessenger : NSObject +@property(nonatomic, copy) FlutterBinaryMessageHandler capturedHandler; +@property(nonatomic, assign) BOOL shouldThrow; +@property(nonatomic, assign) NSInteger sendCallCount; +@end + +@implementation MockThrowingMessenger + +- (void)sendOnChannel:(NSString*)channel message:(NSData*)message { + _sendCallCount++; + if (_shouldThrow) { + [NSException raise:NSInternalInconsistencyException + format:@"Sending a message before the FlutterEngine has been run."]; + } +} + +- (void)sendOnChannel:(NSString*)channel message:(NSData*)message binaryReply:(FlutterBinaryReply)callback {} + +- (FlutterBinaryMessengerConnection)setMessageHandlerOnChannel:(NSString*)channel + binaryMessageHandler:(FlutterBinaryMessageHandler _Nullable)handler { + _capturedHandler = handler; + return 0; +} + +- (FlutterBinaryMessengerConnection)setMessageHandlerOnChannel:(NSString*)channel + binaryMessageHandler:(FlutterBinaryMessageHandler _Nullable)handler + taskQueue:(NSObject* _Nullable)taskQueue { + _capturedHandler = handler; + return 0; +} + +- (void)cleanUpConnection:(FlutterBinaryMessengerConnection)connection {} + +- (NSObject*)makeBackgroundTaskQueue { return nil; } + +@end + +// Helper stream handler that captures the eventSink passed to it during stream setup. +@interface CapturingSinkHandler : NSObject +@property(nonatomic, copy) FlutterEventSink capturedSink; +@end + +@implementation CapturingSinkHandler + +- (FlutterError*)onListenWithArguments:(id)arguments eventSink:(FlutterEventSink)events { + _capturedSink = events; + return nil; +} + +- (FlutterError*)onCancelWithArguments:(id)arguments { return nil; } + +@end + +@interface AblyStreamsChannelTests : XCTestCase +@end + +@implementation AblyStreamsChannelTests + +// Sets up an AblyStreamsChannel with the given messenger and triggers a "listen#1" call, +// which causes the channel to create a sink and pass it to the handler's onListenWithArguments:. +// Returns the handler so tests can invoke the captured sink. +- (CapturingSinkHandler*)setupChannelWithMessenger:(MockThrowingMessenger*)messenger { + AblyStreamsChannel *channel = [AblyStreamsChannel + streamsChannelWithName:@"test.channel" + binaryMessenger:messenger + codec:[FlutterStandardMethodCodec sharedInstance]]; + + CapturingSinkHandler *handler = [CapturingSinkHandler new]; + [channel setStreamHandlerFactory:^NSObject*(id args) { return handler; }]; + + // Simulate Flutter sending a "listen#1" message to open a stream. + // Arguments must be non-nil: AblyStreamsChannel stores them in an NSMutableDictionary + // which rejects nil values. + NSData *listenMsg = [[FlutterStandardMethodCodec sharedInstance] + encodeMethodCall:[FlutterMethodCall methodCallWithMethodName:@"listen#1" arguments:@{}]]; + messenger.capturedHandler(listenMsg, ^(NSData* reply){}); + + return handler; +} + +// Regression test for: NSInternalInconsistencyException crash when the FlutterEngine is +// suspended after the app is backgrounded. +// The sink block in AblyStreamsChannel must catch the exception instead of propagating it. +- (void)testSinkDoesNotCrashWhenEngineNotRunning { + MockThrowingMessenger *messenger = [MockThrowingMessenger new]; + messenger.shouldThrow = YES; + CapturingSinkHandler *handler = [self setupChannelWithMessenger:messenger]; + + XCTAssertNoThrow(handler.capturedSink(@"state-change-event"), + @"Sink should swallow NSInternalInconsistencyException when engine is stopped"); +} + +// Verify the normal (foreground) code path still works after the fix. +- (void)testSinkEmitsNormallyWhenEngineIsRunning { + MockThrowingMessenger *messenger = [MockThrowingMessenger new]; + messenger.shouldThrow = NO; + CapturingSinkHandler *handler = [self setupChannelWithMessenger:messenger]; + + XCTAssertNoThrow(handler.capturedSink(@"state-change-event")); + XCTAssertEqual(messenger.sendCallCount, 1, + @"Messenger should be called once for a normal event emission"); +} + +@end diff --git a/ios/ably_flutter.podspec b/ios/ably_flutter.podspec index e64e41a29..37f36835d 100644 --- a/ios/ably_flutter.podspec +++ b/ios/ably_flutter.podspec @@ -22,12 +22,16 @@ Pod::Spec.new do |s| s.platform = :ios s.ios.deployment_target = '10.0' - # Flutter.framework does not contain a i386 slice. Only x86_64 simulators are supported. + # Flutter 3.x ships both arm64 and x86_64 simulator slices; no arch restriction needed. s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', - 'VALID_ARCHS[sdk=iphonesimulator*]' => 'x86_64', 'GCC_PREPROCESSOR_DEFINITIONS' => "FLUTTER_PACKAGE_PLUGIN_VERSION=\\@\\\"#{flutter_package_plugin_version}\\\"" } s.resource_bundles = {'ably_flutter' => ['Resources/PrivacyInfo.xcprivacy']} s.swift_version = '5.0' + + s.test_spec 'Tests' do |ts| + ts.source_files = 'Tests/**/*.{h,m}' + ts.framework = 'XCTest' + end end