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
40 changes: 40 additions & 0 deletions .github/workflows/ios_unit_tests.yml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 22 additions & 19 deletions ios/Classes/AblyStreamsChannel.m
Original file line number Diff line number Diff line change
Expand Up @@ -66,26 +66,26 @@ - (void)setStreamHandlerFactory:(NSObject<FlutterStreamHandler> *(^)(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"]) {
Expand All @@ -94,7 +94,7 @@ - (void)setStreamHandlerFactory:(NSObject<FlutterStreamHandler> *(^)(id))factory
callback(nil);
}
};

[_messenger setMessageHandlerOnChannel:_name binaryMessageHandler:messageHandler];
}

Expand All @@ -110,22 +110,25 @@ - (void) reset{
- (void)listenForCall:(FlutterMethodCall*)call withKey:(NSNumber*)key usingCallback:(FlutterBinaryReply)callback andFactory:(NSObject<FlutterStreamHandler> *(^)(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]];
}
Expand All @@ -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]];
}

Expand Down
111 changes: 111 additions & 0 deletions ios/Tests/AblyStreamsChannelTests.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#import <XCTest/XCTest.h>
#import <Flutter/Flutter.h>
#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 <FlutterBinaryMessenger>
@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<FlutterTaskQueue>* _Nullable)taskQueue {
_capturedHandler = handler;
return 0;
}

- (void)cleanUpConnection:(FlutterBinaryMessengerConnection)connection {}

- (NSObject<FlutterTaskQueue>*)makeBackgroundTaskQueue { return nil; }

@end

// Helper stream handler that captures the eventSink passed to it during stream setup.
@interface CapturingSinkHandler : NSObject <FlutterStreamHandler>
@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<FlutterStreamHandler>*(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
8 changes: 6 additions & 2 deletions ios/ably_flutter.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading