diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt index 5b2361db2c9f..56588a2928bd 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandler.kt @@ -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, events: EventSink) { @@ -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() } } } diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt index a70556caf16a..6694c2037c41 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPlugin.kt @@ -101,51 +101,6 @@ class FlutterFirebaseFunctionsPlugin : FlutterPlugin, FlutterFirebasePlugin, Clo return taskCompletionSource.task } - private fun getExceptionDetails(exception: Exception?): Map { - val details: MutableMap = 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> { val taskCompletionSource = TaskCompletionSource>() @@ -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 { + val details: MutableMap = 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, callback: (Result) -> Unit) { diff --git a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt index d6224703433d..46899fe0ef35 100644 --- a/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt +++ b/packages/cloud_functions/cloud_functions/android/src/main/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriber.kt @@ -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 { +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 { private var subscription: Subscription? = null - private val mainThreadHandler = Handler(Looper.getMainLooper()) - override fun onSubscribe(s: Subscription) { this.subscription = s subscription!!.request(Long.MAX_VALUE) @@ -25,23 +29,50 @@ class StreamResponseSubscriber(private val eventSink: EventSink?) : Subscriber? { + 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 = + 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() } } } @@ -51,3 +82,8 @@ class StreamResponseSubscriber(private val eventSink: EventSink?) : Subscriber Tasks.forResult(null), + Runnable::run, + // Drops the security provider install, which needs a device. + command -> {}); + } +} diff --git a/packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFunctionsExceptions.java b/packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFunctionsExceptions.java new file mode 100644 index 000000000000..c7291096c9ef --- /dev/null +++ b/packages/cloud_functions/cloud_functions/android/src/test/java/io/flutter/plugins/firebase/functions/TestFunctionsExceptions.java @@ -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); + } +} diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandlerTest.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandlerTest.kt new file mode 100644 index 000000000000..2a31a205a2d6 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FirebaseFunctionsStreamHandlerTest.kt @@ -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) + } +} diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPluginTest.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPluginTest.kt index dc3306864872..dfe9211195d1 100644 --- a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPluginTest.kt +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/FlutterFirebaseFunctionsPluginTest.kt @@ -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 @@ -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( diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt new file mode 100644 index 000000000000..27cc872ee4f3 --- /dev/null +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/RecordingEventSink.kt @@ -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>() + + 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")) + } +} diff --git a/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt new file mode 100644 index 000000000000..306ecf37bb1d --- /dev/null +++ b/packages/cloud_functions/cloud_functions/android/src/test/kotlin/io/flutter/plugins/firebase/functions/StreamResponseSubscriberTest.kt @@ -0,0 +1,213 @@ +// 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 com.google.firebase.functions.FirebaseFunctionsException +import java.io.IOException +import java.net.SocketTimeoutException +import java.util.concurrent.Executor +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class StreamResponseSubscriberTest { + private val eventSink = RecordingEventSink() + private val subscriber = StreamResponseSubscriber(eventSink, Executor { it.run() }) + + @Test + fun onError_sendsFunctionsErrorThenEndsStream() { + val message = "The function must be called while authenticated." + + subscriber.onError( + TestFunctionsExceptions.create( + message, FirebaseFunctionsException.Code.UNAUTHENTICATED, mapOf("foo" to "bar"), null)) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + message, + mapOf( + "code" to "unauthenticated", + "message" to message, + "additionalData" to mapOf("foo" to "bar"))), + listOf("endOfStream")), + eventSink.events) + } + + // PublisherStream reports an error event from the function as INTERNAL, with the decoded error + // as details and its toString() as message. + private fun streamErrorEvent(error: Map) = + TestFunctionsExceptions.create( + error.toString(), FirebaseFunctionsException.Code.INTERNAL, error, null) + + @Test + fun onError_reportsTheCodeAndMessageTheFunctionSent() { + subscriber.onError( + streamErrorEvent( + mapOf( + "status" to "INVALID_ARGUMENT", + "message" to "Invalid test requested.", + "details" to mapOf("field" to "type")))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "Invalid test requested.", + mapOf( + "code" to "invalid-argument", + "message" to "Invalid test requested.", + "additionalData" to mapOf("field" to "type"))), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_reportsAnUnknownStatusAsInternal() { + subscriber.onError( + streamErrorEvent( + mapOf( + "status" to "NOT_A_CODE", + "message" to "Something went wrong.", + "details" to mapOf("field" to "type")))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "INTERNAL", + mapOf("code" to "internal", "message" to "INTERNAL")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_keepsTheMessageWhenTheFunctionSentNoStatus() { + subscriber.onError(streamErrorEvent(mapOf("message" to "Something went wrong."))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "Something went wrong.", + mapOf("code" to "internal", "message" to "Something went wrong.")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_usesTheCodeNameWhenTheFunctionSentNoMessage() { + subscriber.onError(streamErrorEvent(mapOf("status" to "NOT_FOUND"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "NOT_FOUND", + mapOf("code" to "not-found", "message" to "NOT_FOUND")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_mapsNetworkFailuresLikeCallableErrors() { + subscriber.onError( + TestFunctionsExceptions.create( + "INTERNAL", + FirebaseFunctionsException.Code.INTERNAL, + null, + IOException("Failed to connect"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "INTERNAL", + mapOf("code" to "unavailable", "message" to "UNAVAILABLE")), + listOf("endOfStream")), + eventSink.events) + } + + // PublisherStream reports an error while reading the stream as INTERNAL, with the error as + // details rather than as cause. + private fun streamReadError(message: String, error: Throwable) = + TestFunctionsExceptions.create(message, FirebaseFunctionsException.Code.INTERNAL, error, null) + + @Test + fun onError_mapsStreamReadFailuresLikeNetworkErrors() { + subscriber.onError( + streamReadError("unexpected end of stream", IOException("unexpected end of stream"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "unexpected end of stream", + mapOf("code" to "unavailable", "message" to "UNAVAILABLE")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_mapsStreamReadTimeoutsToDeadlineExceeded() { + subscriber.onError(streamReadError("timeout", SocketTimeoutException("timeout"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "timeout", + mapOf("code" to "deadline-exceeded", "message" to "DEADLINE_EXCEEDED")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_doesNotSendTheErrorBehindInvalidJson() { + subscriber.onError(streamReadError("Invalid JSON: {", IllegalStateException("Unterminated"))) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "Invalid JSON: {", + mapOf("code" to "internal", "message" to "Invalid JSON: {")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onError_reportsOtherThrowablesAsUnknown() { + subscriber.onError(IllegalStateException("boom")) + + assertEquals( + listOf( + listOf( + "error", + "firebase_functions", + "boom", + mapOf("code" to "unknown", "message" to "boom")), + listOf("endOfStream")), + eventSink.events) + } + + @Test + fun onComplete_endsStream() { + subscriber.onComplete() + + assertEquals(listOf(listOf("endOfStream")), eventSink.events) + } +} diff --git a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart index 7cb29c6bd9cd..75a83a5d2c05 100644 --- a/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart +++ b/packages/cloud_functions/cloud_functions/example/integration_test/e2e_test.dart @@ -425,6 +425,24 @@ void main() { }, ); + test('emits a [FirebaseFunctionsException] on error', () async { + await expectLater( + callable.stream({}), + emitsInOrder([ + emitsError( + isA() + .having((e) => e.code, 'code', 'invalid-argument') + .having( + (e) => e.message, + 'message', + contains('Invalid test requested.'), + ), + ), + emitsDone, + ]), + ); + }); + test('should emit a [Result] as last value', () async { final stream = await callable.stream().last; expect( diff --git a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift index 0e679295c8c7..4268f681caac 100644 --- a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift +++ b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FirebaseFunctionsPlugin.swift @@ -141,7 +141,7 @@ public class FirebaseFunctionsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt function.call(parameters) { result, error in if let error { - let flutterError = self.createFlutterError(from: error) + let flutterError = Self.createFlutterError(from: error) completion(nil, flutterError) } else { completion(result?.data, nil) @@ -156,7 +156,7 @@ public class FirebaseFunctionsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt return Functions.functions(app: app, region: region ?? "") } - private func createFlutterError(from error: Error) -> FlutterError { + static func createFlutterError(from error: Error) -> FlutterError { let nsError = error as NSError var errorCode = "unknown" var additionalDetails: [String: Any] = [:] @@ -179,7 +179,7 @@ public class FirebaseFunctionsPlugin: NSObject, FLTFirebasePluginProtocol, Flutt ) } - private func mapFunctionsErrorCode(_ code: Int) -> String { + private static func mapFunctionsErrorCode(_ code: Int) -> String { switch code { case FunctionsErrorCode.aborted.rawValue: return "aborted" case FunctionsErrorCode.alreadyExists.rawValue: return "already-exists" diff --git a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift index dbf12e83156b..2422e6e4a5d9 100644 --- a/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift +++ b/packages/cloud_functions/cloud_functions/ios/cloud_functions/Sources/cloud_functions/FunctionsStreamHandler.swift @@ -44,6 +44,7 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { details: nil ) ) + events(FlutterEndOfEventStream) } return } @@ -86,6 +87,7 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { details: nil ) ) + events(FlutterEndOfEventStream) } return } @@ -112,13 +114,8 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { } } catch { await MainActor.run { - events( - FlutterError( - code: "unknown", - message: error.localizedDescription, - details: ["code": "unknown", "message": error.localizedDescription] - ) - ) + events(FirebaseFunctionsPlugin.createFlutterError(from: error)) + events(FlutterEndOfEventStream) } } } else { @@ -130,6 +127,7 @@ class FunctionsStreamHandler: NSObject, FlutterStreamHandler { details: nil ) ) + events(FlutterEndOfEventStream) } } } diff --git a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart index 5634790dfaee..3eacb87052e3 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/firebase_functions_exception.dart @@ -52,9 +52,7 @@ import 'package:meta/meta.dart'; /// code, [message] its message and [details] its details. See /// [Handle errors](https://firebase.google.com/docs/functions/callable#handle-errors-client). /// -/// Errors from `HttpsCallable.stream` are reported with these codes on web -/// only. On Apple platforms, stream errors have the code `unknown`. On -/// Android, a stream that fails ends without an error. +/// Errors from `HttpsCallable.stream` are reported with these codes too. class FirebaseFunctionsException extends FirebaseException implements Exception { // ignore: public_member_api_docs diff --git a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart index e7e1b0ffe5c1..014576c86114 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/method_channel_https_callable.dart @@ -80,7 +80,12 @@ class MethodChannelHttpsCallable extends HttpsCallablePlatform { 'allowInsecureTokenAttachment': functions.allowInsecureTokenAttachment, 'timeout': options.timeout.inMilliseconds, }; - yield* channel.receiveBroadcastStream(eventData).map(_convertNested); + // Stream errors are forwarded by `yield*` rather than thrown into the + // `catch` below, so they are converted on the stream itself. + yield* channel + .receiveBroadcastStream(eventData) + .handleError(convertPlatformException) + .map(_convertNested); } catch (e, s) { convertPlatformException(e, s); } diff --git a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart index b97fd057914a..c006a42ff1ce 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/lib/src/method_channel/utils/exception.dart @@ -46,7 +46,7 @@ FirebaseException platformExceptionToFirebaseFunctionsException( return FirebaseFunctionsException( code: code, - message: message!, + message: message ?? '', details: additionalData, stackTrace: stackTrace, ); diff --git a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart index 421592b25fce..9a4a371e216d 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/method_channel_https_callable_test.dart @@ -28,6 +28,7 @@ void main() { HttpsCallableOptions kOptions = HttpsCallableOptions(); String kPlatformExceptionMessage = 'Mock platform exception thrown'; Map? lastCallArguments; + void Function(String eventChannelId)? onRegisterEventChannel; group('$MethodChannelHttpsCallable', () { setUpAll(() async { @@ -43,6 +44,8 @@ void main() { code: 'UNKNOWN', message: kPlatformExceptionMessage); } return kParameters; + }, (arguments) { + onRegisterEventChannel?.call(arguments['eventChannelId']! as String); })); functions = @@ -60,6 +63,7 @@ void main() { mockPlatformExceptionThrown = false; mockExceptionThrown = false; lastCallArguments = null; + onRegisterEventChannel = null; functions!.allowInsecureTokenAttachment = false; httpsCallable!.options = kOptions; }); @@ -166,19 +170,120 @@ void main() { isTrue, ); }); + + group('errors', () { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + // Mocks the native side of the stream's event channel, which is only + // known once `stream()` registers it. + void mockNativeStream( + void Function(MockStreamHandlerEventSink events) onListen, + ) { + onRegisterEventChannel = (eventChannelId) { + final channel = EventChannel( + 'plugins.flutter.io/firebase_functions/$eventChannelId'); + messenger.setMockStreamHandler( + channel, + MockStreamHandler.inline( + onListen: (arguments, events) => onListen(events), + ), + ); + addTearDown(() => messenger.setMockStreamHandler(channel, null)); + }; + } + + test('are emitted as [FirebaseFunctionsException]', () async { + mockNativeStream((events) { + events.success({'message': 'chunk'}); + events.error( + code: 'firebase_functions', + message: 'The function must be called while authenticated.', + details: { + 'code': 'unauthenticated', + 'message': 'The function must be called while authenticated.', + 'additionalData': {'foo': 'bar'}, + }, + ); + events.endOfStream(); + }); + + await expectLater( + httpsCallable!.stream(null), + emitsInOrder([ + {'message': 'chunk'}, + emitsError( + isA() + .having((e) => e.code, 'code', 'unauthenticated') + .having((e) => e.message, 'message', + 'The function must be called while authenticated.') + .having((e) => e.details, 'details', {'foo': 'bar'}), + ), + emitsDone, + ]), + ); + }); + + test('without details are emitted as [FirebaseFunctionsException]', + () async { + mockNativeStream((events) { + events.error(code: 'firebase_functions', message: 'Bad arguments'); + events.endOfStream(); + }); + + await expectLater( + httpsCallable!.stream(null), + emitsInOrder([ + emitsError( + isA() + .having((e) => e.code, 'code', 'unknown') + .having((e) => e.message, 'message', 'Bad arguments'), + ), + emitsDone, + ]), + ); + }); + + test('without a message are emitted as [FirebaseFunctionsException]', + () async { + mockNativeStream((events) { + events.error( + code: 'firebase_functions', + details: {'code': 'unknown'}, + ); + events.endOfStream(); + }); + + await expectLater( + httpsCallable!.stream(null), + emitsInOrder([ + emitsError( + isA() + .having((e) => e.code, 'code', 'unknown') + .having((e) => e.message, 'message', isEmpty), + ), + emitsDone, + ]), + ); + }); + }); }); }); } class _TestCloudFunctionsHostApi implements TestCloudFunctionsHostApi { - _TestCloudFunctionsHostApi(this.callHandler); + _TestCloudFunctionsHostApi( + this.callHandler, this.registerEventChannelHandler); final Future Function(Map arguments) callHandler; + final void Function(Map arguments) + registerEventChannelHandler; @override Future call(Map arguments) => callHandler(arguments); @override - Future registerEventChannel(Map arguments) async {} + Future registerEventChannel(Map arguments) async => + registerEventChannelHandler(arguments); } diff --git a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart index 0af22be8f48e..72a28e48f40d 100644 --- a/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart +++ b/packages/cloud_functions/cloud_functions_platform_interface/test/method_channel/utils/exception_test.dart @@ -42,6 +42,20 @@ void main() { ); }); + test('should tolerate a missing message', () async { + PlatformException platformException = PlatformException( + code: 'foo', details: {'code': 'internal', 'message': null}); + + expect( + () => convertPlatformException(platformException, StackTrace.empty), + throwsA( + isA() + .having((e) => e.code, 'code', 'internal') + .having((e) => e.message, 'message', isEmpty), + ), + ); + }); + test('should override code and message if provided to additional details', () async { String code = 'baz';