Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,7 +56,9 @@ class FronteggMethodCallHandler {
getPermissionEntitlement(call: call, result: result)
case "openAdminPortal":
openAdminPortal(result: result)

case "forceStateUpdate":
forceStateUpdate(result: result)

default:
result(FlutterMethodNotImplemented)
}
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never> {
return Publishers.MergeMany(
Expand Down Expand Up @@ -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

Expand Down
Loading