From 372542c8f0e6a93346f288dcec0fd0c19e871943 Mon Sep 17 00:00:00 2001 From: dianaKhortiuk-frontegg Date: Wed, 22 Jul 2026 13:33:54 +0300 Subject: [PATCH] fix(flutter): make forceStateUpdate emit state; fix iOS listener leak (FR-25944) forceStateUpdate() never delivered a refreshed state to Flutter, and on iOS it threw MissingPluginException because the method had no handler case. - Android: forceStateUpdate was a comment-only no-op that completed the result without notifying the state listener. Add forceNotifyChanges() to the FronteggStateListener interface and route forceStateUpdate through an extracted, unit-tested top-level forceStateUpdate(listener, result) that calls it. - iOS: add the missing "forceStateUpdate" case and a handler that triggers a one-off state emit via the listener (wired into FronteggMethodCallHandler in register()). - iOS: FronteggStateListenerImpl.subscribe() appended Combine sinks to cancellables without clearing prior ones, so every EventChannel onListen (e.g. hot restart) leaked sinks and fired updateState() once per accumulated sink. Call dispose() at the start of subscribe(). forceNotifyChanges() hops to a background queue before updateState() to avoid the DispatchQueue.main.sync deadlock when invoked from the main-thread method channel. Test: ForceStateUpdateTest (Android, RED->GREEN). --- .../flutter/FronteggMethodCallHandler.kt | 19 +++++-- .../stateListener/FronteggStateListener.kt | 3 ++ .../FronteggStateListenerImpl.kt | 2 +- .../frontegg/flutter/ForceStateUpdateTest.kt | 52 +++++++++++++++++++ .../flutter/FronteggFlutterPluginTest.kt | 27 ---------- .../FronteggFlutterPlugin.swift | 1 + .../FronteggMethodCallHandler.swift | 19 ++++++- .../stateListener/FronteggStateListener.swift | 5 +- .../FronteggStateListenerImpl.swift | 14 +++++ 9 files changed, 106 insertions(+), 36 deletions(-) create mode 100644 android/src/test/kotlin/com/frontegg/flutter/ForceStateUpdateTest.kt delete mode 100644 android/src/test/kotlin/com/frontegg/flutter/FronteggFlutterPluginTest.kt diff --git a/android/src/main/kotlin/com/frontegg/flutter/FronteggMethodCallHandler.kt b/android/src/main/kotlin/com/frontegg/flutter/FronteggMethodCallHandler.kt index 4c0fac98..7045af12 100644 --- a/android/src/main/kotlin/com/frontegg/flutter/FronteggMethodCallHandler.kt +++ b/android/src/main/kotlin/com/frontegg/flutter/FronteggMethodCallHandler.kt @@ -8,6 +8,7 @@ import com.frontegg.android.exceptions.FronteggException import com.frontegg.android.fronteggAuth import com.frontegg.android.models.Entitlement import com.frontegg.android.services.StorageProvider +import com.frontegg.flutter.stateListener.FronteggStateListener import com.frontegg.flutter.stateListener.FronteggStateListenerImpl import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -412,11 +413,8 @@ class FronteggMethodCallHandler( ) } - private fun forceStateUpdate(result: MethodChannel.Result) { - // Simple force state update - // The state listener will automatically handle state updates - result.success(null) - } + private fun forceStateUpdate(result: MethodChannel.Result) = + forceStateUpdate(stateListener, result) private fun loadEntitlements( call: MethodCall, @@ -475,4 +473,15 @@ class FronteggMethodCallHandler( AdminPortalActivity.open(activity) result.success(null) } +} + +/** + * Emits the current auth state to Flutter on demand (FR-25944). `forceStateUpdate` used to be a + * no-op that completed the result without ever notifying the listener, so a `forceStateUpdate()` + * call from Dart never delivered a refreshed state. Top-level so it can be unit-tested without a + * Context/Activity (which can't be mocked in this toolchain). + */ +internal fun forceStateUpdate(stateListener: FronteggStateListener?, result: MethodChannel.Result) { + stateListener?.forceNotifyChanges() + result.success(null) } \ No newline at end of file diff --git a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListener.kt b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListener.kt index 337cbc3c..6bad76e2 100644 --- a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListener.kt +++ b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListener.kt @@ -7,4 +7,7 @@ interface FronteggStateListener { fun setEventSink(eventSink: EventChannel.EventSink?) fun subscribe() fun dispose() + + /** Emit the current auth state to Flutter on demand (used by `forceStateUpdate`, FR-25944). */ + fun forceNotifyChanges() } diff --git a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt index 7f0769bf..6afea163 100644 --- a/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt +++ b/android/src/main/kotlin/com/frontegg/flutter/stateListener/FronteggStateListenerImpl.kt @@ -48,7 +48,7 @@ class FronteggStateListenerImpl( * Force notify changes to Flutter * This is useful for hosted mode when state changes don't trigger automatically */ - fun forceNotifyChanges() { + override fun forceNotifyChanges() { notifyChanges() } diff --git a/android/src/test/kotlin/com/frontegg/flutter/ForceStateUpdateTest.kt b/android/src/test/kotlin/com/frontegg/flutter/ForceStateUpdateTest.kt new file mode 100644 index 00000000..9a5a81a9 --- /dev/null +++ b/android/src/test/kotlin/com/frontegg/flutter/ForceStateUpdateTest.kt @@ -0,0 +1,52 @@ +package com.frontegg.flutter + +import com.frontegg.flutter.stateListener.FronteggStateListener +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * FR-25944: `forceStateUpdate` was a comment-only no-op that called `result.success(null)` + * without ever notifying the state listener, so Dart never received the refreshed state. + * The extracted `forceStateUpdate(listener, result)` must trigger `forceNotifyChanges()` and + * still complete the result. + */ +internal class ForceStateUpdateTest { + + private class RecordingResult : MethodChannel.Result { + var successCalled = false + override fun success(result: Any?) { successCalled = true } + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {} + override fun notImplemented() {} + } + + private class FakeListener : FronteggStateListener { + var forceCount = 0 + override fun setEventSink(eventSink: EventChannel.EventSink?) {} + override fun subscribe() {} + override fun dispose() {} + override fun forceNotifyChanges() { forceCount++ } + } + + @Test + fun forceStateUpdate_notifiesListener_andCompletesResult() { + val listener = FakeListener() + val result = RecordingResult() + + forceStateUpdate(listener, result) + + assertEquals(1, listener.forceCount, "forceStateUpdate must trigger forceNotifyChanges so Dart receives the refreshed state") + assertTrue(result.successCalled, "result must be completed") + } + + @Test + fun forceStateUpdate_noListener_stillCompletesResult() { + val result = RecordingResult() + + forceStateUpdate(null, result) + + assertTrue(result.successCalled, "result must be completed even when no listener is attached") + } +} diff --git a/android/src/test/kotlin/com/frontegg/flutter/FronteggFlutterPluginTest.kt b/android/src/test/kotlin/com/frontegg/flutter/FronteggFlutterPluginTest.kt deleted file mode 100644 index e3a96ae3..00000000 --- a/android/src/test/kotlin/com/frontegg/flutter/FronteggFlutterPluginTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.frontegg.flutter - -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import kotlin.test.Test -import org.mockito.Mockito - -/* - * This demonstrates a simple unit test of the Kotlin portion of this plugin's implementation. - * - * Once you have built the plugin's example app, you can run these tests from the command - * line by running `./gradlew testDebugUnitTest` in the `example/android/` directory, or - * you can run them directly from IDEs that support JUnit such as Android Studio. - */ - -internal class FronteggFlutterPluginTest { - @Test - fun onMethodCall_getPlatformVersion_returnsExpectedValue() { - val plugin = FronteggFlutterPlugin() - - val call = MethodCall("getPlatformVersion", null) - val mockResult: MethodChannel.Result = Mockito.mock(MethodChannel.Result::class.java) - plugin.onMethodCall(call, mockResult) - - Mockito.verify(mockResult).success("Android " + android.os.Build.VERSION.RELEASE) - } -} diff --git a/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggFlutterPlugin.swift b/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggFlutterPlugin.swift index b46c1297..5378bd8f 100644 --- a/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggFlutterPlugin.swift +++ b/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggFlutterPlugin.swift @@ -15,6 +15,7 @@ public class FronteggFlutterPlugin: NSObject, FlutterPlugin { let stateEventChannel = FlutterEventChannel(name: stateEventChanelName, binaryMessenger: registrar.messenger()) stateListener = FronteggStateListenerImpl(fronteggApp: fronteggApp) + methodCallHandler.setStateListener(stateListener!) let streamHandler = StateStreamHandler(stateListener: stateListener!) stateEventChannel.setStreamHandler(streamHandler) diff --git a/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggMethodCallHandler.swift b/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggMethodCallHandler.swift index 84820224..82d37240 100644 --- a/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggMethodCallHandler.swift +++ b/ios/frontegg_flutter/Sources/frontegg_flutter/FronteggMethodCallHandler.swift @@ -6,10 +6,15 @@ import UIKit class FronteggMethodCallHandler { private var fronteggApp: FronteggApp - + private var stateListener: FronteggStateListener? = nil + init(fronteggApp: FronteggApp) { self.fronteggApp = fronteggApp } + + func setStateListener(_ listener: FronteggStateListener) { + self.stateListener = listener + } public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { @@ -51,7 +56,9 @@ class FronteggMethodCallHandler { getPermissionEntitlement(call: call, result: result) case "openAdminPortal": openAdminPortal(result: result) - + case "forceStateUpdate": + forceStateUpdate(result: result) + default: result(FlutterMethodNotImplemented) } @@ -334,6 +341,14 @@ class FronteggMethodCallHandler { } } + private func forceStateUpdate(result: @escaping FlutterResult) { + // FR-25944: iOS had no case for "forceStateUpdate" → the default branch returned + // FlutterMethodNotImplemented, surfacing as MissingPluginException in Dart. Trigger a + // one-off state emit so the Flutter side receives the current auth state on demand. + stateListener?.forceNotifyChanges() + result(nil) + } + private func refreshToken(result: @escaping FlutterResult) { DispatchQueue.global(qos: .userInteractive).async { Task { diff --git a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListener.swift b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListener.swift index 24114192..9ba5d603 100644 --- a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListener.swift +++ b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListener.swift @@ -5,6 +5,9 @@ protocol FronteggStateListener { func setEventSink(eventSink: FlutterEventSink?) func subscribe() - + func dispose() + + /// Emit the current auth state to Flutter on demand (used by `forceStateUpdate`, FR-25944). + func forceNotifyChanges() } diff --git a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift index 4544f070..e043cd6f 100644 --- a/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift +++ b/ios/frontegg_flutter/Sources/frontegg_flutter/stateListener/FronteggStateListenerImpl.swift @@ -18,6 +18,11 @@ class FronteggStateListenerImpl: FronteggStateListener { } func subscribe() { + // Cancel any prior subscriptions first: onListen can run again (e.g. Flutter hot restart + // or re-listen) and appending to `cancellables` without clearing leaked Combine sinks and + // fired updateState() once per accumulated sink (FR-25944). + dispose() + let auth = fronteggApp.auth var stateChange: AnyPublisher { return Publishers.MergeMany( @@ -54,6 +59,15 @@ class FronteggStateListenerImpl: FronteggStateListener { }).store(in: &cancellables) } + func forceNotifyChanges() { + // Hop off the calling thread before updateState(): sendState() uses DispatchQueue.main.sync, + // and forceStateUpdate is invoked from the method-channel handler on the main thread, so a + // direct call would deadlock. This mirrors how the Combine sinks dispatch updateState(). + DispatchQueue.global(qos: .userInteractive).async { + self.updateState() + } + } + private func updateState() { let auth = self.fronteggApp.auth