fix(ios): resolve UIScene lifecycle crash in plugin registration#158
fix(ios): resolve UIScene lifecycle crash in plugin registration#158binSaed wants to merge 1 commit intojitsi:mainfrom
Conversation
The plugin was force-unwrapping UIApplication.shared.delegate?.window??.rootViewController during register(with:), which is nil in UIScene lifecycle because the window is managed by the SceneDelegate, not the AppDelegate. Replace the eager rootViewController resolution at registration time with a lazy computed property that resolves at use-time via UIScene APIs, with fallback to the legacy window approach for pre-iOS 13. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a critical iOS crash that occurred during plugin registration when apps use the UIScene lifecycle (iOS 13+). The original implementation force-unwrapped UIApplication.shared.delegate?.window??.rootViewController at registration time, which is nil in UIScene-based apps where windows are managed by SceneDelegate. The fix replaces the eager initialization with a lazy computed property that resolves the root view controller at use-time, with proper fallbacks for different iOS versions.
Changes:
- Removed stored
flutterViewControllerproperty and its initialization in the plugin constructor - Added lazy computed
rootViewControllerproperty with iOS 15.0+, iOS 13.0+, and legacy fallback paths - Added error handling for when root view controller is unavailable
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return UIApplication.shared.connectedScenes | ||
| .compactMap { $0 as? UIWindowScene } | ||
| .flatMap { $0.windows } | ||
| .first { $0.isKeyWindow }?.rootViewController |
There was a problem hiding this comment.
On iOS 13-14, if there are multiple windows and none of them have isKeyWindow set to true (which can happen in certain scenarios), this will return nil even though valid windows exist. Consider adding a fallback to return the rootViewController from the first window if no key window is found, before falling back to the legacy approach.
| return UIApplication.shared.connectedScenes | |
| .compactMap { $0 as? UIWindowScene } | |
| .flatMap { $0.windows } | |
| .first { $0.isKeyWindow }?.rootViewController | |
| let windows = UIApplication.shared.connectedScenes | |
| .compactMap { $0 as? UIWindowScene } | |
| .flatMap { $0.windows } | |
| if let keyWindow = windows.first(where: { $0.isKeyWindow }) { | |
| return keyWindow.rootViewController | |
| } | |
| if let firstWindow = windows.first { | |
| return firstWindow.rootViewController | |
| } |
| jitsiMeetViewController!.modalPresentationStyle = .overFullScreen | ||
| flutterViewController.present(jitsiMeetViewController!, animated: true) | ||
| presenter.present(jitsiMeetViewController!, animated: true) | ||
| result("Successfully joined meeting \(room)") |
There was a problem hiding this comment.
The success message includes string interpolation of room which is an optional String. If room is nil, this will print "Successfully joined meeting nil". While this doesn't cause a crash, it creates an unclear message. Consider using nil coalescing operator to provide a more meaningful message, or unwrap room safely before interpolation.
| result("Successfully joined meeting \(room)") | |
| let successMessage: String | |
| if let roomName = room { | |
| successMessage = "Successfully joined meeting \(roomName)" | |
| } else { | |
| successMessage = "Successfully joined meeting" | |
| } | |
| result(successMessage) |
| return | ||
| } | ||
|
|
||
| jitsiMeetViewController = JitsiMeetViewController.init(options: options, eventSink: eventSink!) |
There was a problem hiding this comment.
Force-unwrapping eventSink with the ! operator can cause a crash if eventSink is nil. This can happen if the join method is called before the event channel listener has been set up via onListen. Consider using optional chaining or a guard statement to check if eventSink is available before initializing JitsiMeetViewController, and return an appropriate error if it's nil.
| jitsiMeetViewController = JitsiMeetViewController.init(options: options, eventSink: eventSink!) | |
| guard let eventSink = eventSink else { | |
| result(FlutterError(code: "NO_EVENT_SINK", message: "Event sink not available. Ensure the event channel listener is attached before joining.", details: nil)) | |
| return | |
| } | |
| jitsiMeetViewController = JitsiMeetViewController.init(options: options, eventSink: eventSink) |
| jitsiMeetViewController!.modalPresentationStyle = .overFullScreen | ||
| flutterViewController.present(jitsiMeetViewController!, animated: true) | ||
| presenter.present(jitsiMeetViewController!, animated: true) |
There was a problem hiding this comment.
Force-unwrapping jitsiMeetViewController with ! can cause a crash if the initialization of JitsiMeetViewController fails. While this is unlikely with the current API, it's safer to use optional chaining or guard statement to handle potential nil values. This also applies to line 123.
| return UIApplication.shared.connectedScenes | ||
| .compactMap { $0 as? UIWindowScene } | ||
| .first { $0.activationState == .foregroundActive }? | ||
| .keyWindow?.rootViewController |
There was a problem hiding this comment.
The iOS 15.0+ implementation filters for scenes with activationState equal to foregroundActive. This means if the join method is called when the app is in a different state (like foregroundInactive during a transition), rootViewController will be nil and the join will fail with NO_VIEW_CONTROLLER error. Consider checking for foregroundInactive as well, or removing the activation state filter and just taking the first available scene, with a fallback to check other scenes if needed.
| return UIApplication.shared.connectedScenes | |
| .compactMap { $0 as? UIWindowScene } | |
| .first { $0.activationState == .foregroundActive }? | |
| .keyWindow?.rootViewController | |
| let windowScenes = UIApplication.shared.connectedScenes | |
| .compactMap { $0 as? UIWindowScene } | |
| if let activeScene = windowScenes.first(where: { $0.activationState == .foregroundActive || $0.activationState == .foregroundInactive }), | |
| let rootVC = activeScene.keyWindow?.rootViewController { | |
| return rootVC | |
| } | |
| if let fallbackScene = windowScenes.first, | |
| let rootVC = fallbackScene.keyWindow?.rootViewController { | |
| return rootVC | |
| } |
|
Closes #152 |
|
@saghul can you have a look at this? |
|
We don't support iOS < 15 in the Jitsi SDK. |
|
@saghul
|
|
What I meant is that you can drop the compatibility code for older versions from your PR. |
|
@saghul Please review this PR; my iOS app is crashing because of this issue |
@binSaed is it ready to be tested? We are getting ready for a new release/ |

The plugin was force-unwrapping UIApplication.shared.delegate?.window??.rootViewController during register(with:), which is nil in UIScene lifecycle because the window is managed by the SceneDelegate, not the AppDelegate.
Replace the eager rootViewController resolution at registration time with a lazy computed property that resolves at use-time via UIScene APIs, with fallback to the legacy window approach for pre-iOS 13.
fix #154