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