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
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ class FirebaseFunctionsStreamHandler(private val firebaseFunctions: FirebaseFunc
}

override fun onCancel(arguments: Any) {
subscriber!!.cancel()
// Null when the stream failed before subscribing.
subscriber?.cancel()
}

private fun httpsStreamCall(arguments: Map<String, Any>, events: EventSink) {
Expand Down Expand Up @@ -67,7 +68,9 @@ class FirebaseFunctionsStreamHandler(private val firebaseFunctions: FirebaseFunc
subscriber = StreamResponseSubscriber(events)
publisher.subscribe(subscriber)
} catch (e: Exception) {
events.error("firebase_functions", e.message, null)
events.error(
"firebase_functions", e.message, FlutterFirebaseFunctionsPlugin.getExceptionDetails(e))
events.endOfStream()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -101,51 +101,6 @@ class FlutterFirebaseFunctionsPlugin : FlutterPlugin, FlutterFirebasePlugin, Clo
return taskCompletionSource.task
}

private fun getExceptionDetails(exception: Exception?): Map<String, Any?> {
val details: MutableMap<String, Any?> = HashMap()

if (exception == null) {
return details
}

var code = "UNKNOWN"
var message = exception.message
var additionalData: Any? = null

if (exception.cause is FirebaseFunctionsException) {
val functionsException = exception.cause as FirebaseFunctionsException?
code = functionsException!!.code.name
message = functionsException.message
additionalData = functionsException.details

if (functionsException.cause is IOException &&
"Canceled" == (functionsException.cause as IOException).message) {
// return DEADLINE_EXCEEDED for IOException cancel errors, to match iOS & Web
code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
} else if (functionsException.cause is
InterruptedIOException // return DEADLINE_EXCEEDED for InterruptedIOException errors, to
// match iOS & Web
&& "timeout" == (functionsException.cause as InterruptedIOException).message) {
code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
} else if (functionsException.cause is IOException) {
// return UNAVAILABLE for network io errors, to match iOS & Web
code = FirebaseFunctionsException.Code.UNAVAILABLE.name
message = FirebaseFunctionsException.Code.UNAVAILABLE.name
}
}

details["code"] = mapFunctionsErrorCode(code)
details["message"] = message

if (additionalData != null) {
details["additionalData"] = additionalData
}

return details
}

override fun getPluginConstantsForFirebaseApp(firebaseApp: FirebaseApp): Task<Map<String, Any>> {
val taskCompletionSource = TaskCompletionSource<Map<String, Any>>()

Expand All @@ -169,6 +124,53 @@ class FlutterFirebaseFunctionsPlugin : FlutterPlugin, FlutterFirebasePlugin, Clo
internal fun mapFunctionsErrorCode(code: String): String {
return code.replace("_", "-").lowercase(Locale.ROOT)
}

internal fun getExceptionDetails(exception: Throwable?): Map<String, Any?> {
val details: MutableMap<String, Any?> = HashMap()

if (exception == null) {
return details
}

var code = "UNKNOWN"
var message = exception.message
var additionalData: Any? = null

// Callable errors arrive wrapped by Tasks.await, stream errors unwrapped.
val functionsException =
exception as? FirebaseFunctionsException ?: exception.cause as? FirebaseFunctionsException
if (functionsException != null) {
code = functionsException.code.name
message = functionsException.message
// A stream that fails while reading gets the error as details, not as cause
// (PublisherStream.processSSEStream). The codec can't send a Throwable.
val cause = functionsException.cause ?: functionsException.details as? Throwable
additionalData = functionsException.details.takeUnless { it is Throwable }

if (cause is IOException && "Canceled" == cause.message) {
// return DEADLINE_EXCEEDED for IOException cancel errors, to match iOS & Web
code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
} else if (cause is InterruptedIOException && "timeout" == cause.message) {
// return DEADLINE_EXCEEDED for InterruptedIOException errors, to match iOS & Web
code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name
} else if (cause is IOException) {
// return UNAVAILABLE for network io errors, to match iOS & Web
code = FirebaseFunctionsException.Code.UNAVAILABLE.name
message = FirebaseFunctionsException.Code.UNAVAILABLE.name
}
}

details["code"] = mapFunctionsErrorCode(code)
details["message"] = message

if (additionalData != null) {
details["additionalData"] = additionalData
}

return details
}
}

override fun call(arguments: Map<String, Any?>, callback: (Result<Any?>) -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ package io.flutter.plugins.firebase.functions

import android.os.Handler
import android.os.Looper
import com.google.firebase.functions.FirebaseFunctionsException
import com.google.firebase.functions.StreamResponse
import io.flutter.plugin.common.EventChannel.EventSink
import java.util.concurrent.Executor
import org.reactivestreams.Subscriber
import org.reactivestreams.Subscription

class StreamResponseSubscriber(private val eventSink: EventSink?) : Subscriber<StreamResponse> {
class StreamResponseSubscriber(
private val eventSink: EventSink?,
// Injectable so unit tests can run the posted events without a main Looper.
private val mainThreadExecutor: Executor = mainThreadHandlerExecutor(),
) : Subscriber<StreamResponse> {
private var subscription: Subscription? = null

private val mainThreadHandler = Handler(Looper.getMainLooper())

override fun onSubscribe(s: Subscription) {
this.subscription = s
subscription!!.request(Long.MAX_VALUE)
Expand All @@ -25,23 +29,50 @@ class StreamResponseSubscriber(private val eventSink: EventSink?) : Subscriber<S
if (streamResponse is StreamResponse.Message) {
val message: Any? = (streamResponse).message.data
responseMap["message"] = message
mainThreadHandler.post { eventSink!!.success(responseMap) }
mainThreadExecutor.execute { eventSink!!.success(responseMap) }
} else {
val result: Any? = (streamResponse as StreamResponse.Result).result.data
responseMap["result"] = result
mainThreadHandler.post { eventSink!!.success(responseMap) }
mainThreadExecutor.execute { eventSink!!.success(responseMap) }
}
}

override fun onError(t: Throwable) {
if (eventSink != null) {
mainThreadHandler.post { eventSink.endOfStream() }
val functionError = functionError(t)
val message = functionError?.get("message") as String? ?: t.message
val details = functionError ?: FlutterFirebaseFunctionsPlugin.getExceptionDetails(t)
mainThreadExecutor.execute {
eventSink.error("firebase_functions", message, details)
eventSink.endOfStream()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems android can report the function's real code. PublisherStream sets the code to INTERNAL, but details is the decoded error {status, message, details}, and the exception message is that map's toString(). In StreamResponseSubscriber.onError, parse it the way FirebaseFunctionsException.fromResponse does: status becomes the code, message becomes the message, and only the inner details is kept. Do this on the stream path only. An unknown status stays internal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c10dc15: on the stream path, onError now reads the decoded error the way fromResponse does. status becomes the code, message the message, and only the inner details is kept. As in fromResponse, an unknown status gives internal without message or details. The e2e now expects invalid-argument on Android too and passes on the emulator.

While verifying it I found a crash on this branch, fixed in ac79230. When reading the stream fails after the response started (timeout, dropped connection, bad JSON), PublisherStream passes the error as details, and the codec threw Unsupported value on the main thread. It now maps to deadline-exceeded/unavailable like calls. Details are in the PR description.

}
}

// The SDK reports an error the function sent with code INTERNAL, the decoded error
// ({status, message, details}) as details, and its toString() as message. Read the real code,
// message and details from it, as FirebaseFunctionsException.fromResponse does for calls. Like
// fromResponse, an unknown status makes the whole error INTERNAL, without message or details.
private fun functionError(t: Throwable): Map<String, Any?>? {
val exception = t as? FirebaseFunctionsException ?: return null
if (exception.code != FirebaseFunctionsException.Code.INTERNAL) return null
val error = exception.details as? Map<*, *> ?: return null
val status = error["status"] as? String ?: FirebaseFunctionsException.Code.INTERNAL.name
val code =
FirebaseFunctionsException.Code.values().firstOrNull { it.name == status }
?: return hashMapOf("code" to "internal", "message" to "INTERNAL")
val message = (error["message"] as? String)?.takeIf { it.isNotEmpty() } ?: code.name
val details: MutableMap<String, Any?> =
hashMapOf(
"code" to FlutterFirebaseFunctionsPlugin.mapFunctionsErrorCode(code.name),
"message" to message)
error["details"]?.let { details["additionalData"] = it }
return details
}

override fun onComplete() {
if (eventSink != null) {
mainThreadHandler.post { eventSink.endOfStream() }
mainThreadExecutor.execute { eventSink.endOfStream() }
}
}

Expand All @@ -51,3 +82,8 @@ class StreamResponseSubscriber(private val eventSink: EventSink?) : Subscriber<S
}
}
}

private fun mainThreadHandlerExecutor(): Executor {
val handler = Handler(Looper.getMainLooper())
return Executor { handler.post(it) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright 2026 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package io.flutter.plugins.firebase.functions;

import android.content.ContextWrapper;
import com.google.android.gms.tasks.Tasks;
import com.google.firebase.functions.FirebaseFunctions;

/**
* Creates a {@link FirebaseFunctions} for tests that never reach the network. Its constructor is
* Kotlin {@code internal}, so Kotlin code outside the SDK cannot call it, but Java can.
*/
final class TestFirebaseFunctions {
private TestFirebaseFunctions() {}

static FirebaseFunctions create() {
return new FirebaseFunctions(
new ContextWrapper(null),
"test-project",
"us-central1",
limitedUseAppCheckTokens -> Tasks.forResult(null),
Runnable::run,
// Drops the security provider install, which needs a device.
command -> {});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// Copyright 2026 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package io.flutter.plugins.firebase.functions;

import androidx.annotation.Nullable;
import com.google.firebase.functions.FirebaseFunctionsException;

/**
* Creates {@link FirebaseFunctionsException}s for tests. Its constructors are Kotlin {@code
* internal}, so Kotlin code outside the SDK cannot call them, but Java can.
*/
final class TestFunctionsExceptions {
private TestFunctionsExceptions() {}

static FirebaseFunctionsException create(
String message,
FirebaseFunctionsException.Code code,
@Nullable Object details,
@Nullable Throwable cause) {
// The four-argument constructor rejects a null cause.
return cause == null
? new FirebaseFunctionsException(message, code, details)
: new FirebaseFunctionsException(message, code, details, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2026 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package io.flutter.plugins.firebase.functions

import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4

@RunWith(JUnit4::class)
class FirebaseFunctionsStreamHandlerTest {
private val eventSink = RecordingEventSink()
private val handler = FirebaseFunctionsStreamHandler(TestFirebaseFunctions.create())

// Sets neither functionName nor functionUri, so the stream fails before subscribing.
private val invalidArguments = mapOf("limitedUseAppCheckToken" to false)

@Test
fun onListen_setupFailureSendsErrorThenEndsStream() {
handler.onListen(invalidArguments, eventSink)

val message = "Either functionName or functionUri must be set"
assertEquals(
listOf(
listOf(
"error",
"firebase_functions",
message,
mapOf("code" to "unknown", "message" to message)),
listOf("endOfStream")),
eventSink.events)
}

@Test
fun onCancel_afterSetupFailureDoesNotThrow() {
handler.onListen(invalidArguments, eventSink)

handler.onCancel(invalidArguments)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
// found in the LICENSE file.
package io.flutter.plugins.firebase.functions

import com.google.firebase.functions.FirebaseFunctionsException
import java.util.Locale
import java.util.concurrent.ExecutionException
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
Expand Down Expand Up @@ -48,6 +50,25 @@ class FlutterFirebaseFunctionsPluginTest {
assertMappedCodes()
}

@Test
fun getExceptionDetails_unwrapsCallableErrors() {
// Tasks.await wraps the callable's FirebaseFunctionsException.
val exception =
ExecutionException(
TestFunctionsExceptions.create(
"Invalid test requested.",
FirebaseFunctionsException.Code.INVALID_ARGUMENT,
listOf(1, 2),
null))

assertEquals(
mapOf(
"code" to "invalid-argument",
"message" to "Invalid test requested.",
"additionalData" to listOf(1, 2)),
FlutterFirebaseFunctionsPlugin.getExceptionDetails(exception))
}

private fun assertMappedCodes() {
val expected =
mapOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2026 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package io.flutter.plugins.firebase.functions

import io.flutter.plugin.common.EventChannel.EventSink
import io.flutter.plugin.common.StandardMethodCodec

/**
* Records the events sent to it, in order. Encodes them first, as EventChannel does, so a value the
* codec can't send fails the test instead of crashing the app.
*/
class RecordingEventSink : EventSink {
val events = mutableListOf<List<Any?>>()

override fun success(event: Any?) {
StandardMethodCodec.INSTANCE.encodeSuccessEnvelope(event)
events.add(listOf("success", event))
}

override fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) {
StandardMethodCodec.INSTANCE.encodeErrorEnvelope(errorCode!!, errorMessage, errorDetails)
events.add(listOf("error", errorCode, errorMessage, errorDetails))
}

override fun endOfStream() {
events.add(listOf("endOfStream"))
}
}
Loading
Loading