From 535fb9099d1679fc45f461e855449f83dd85ab68 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 16 Dec 2024 13:57:49 -0800 Subject: [PATCH 1/9] Extend Firebase SDK with new APIs to consume streaming callable function response. - Handling the server-sent event (SSE) parsing internally - Providing proper error handling and connection management - Maintaining memory efficiency for long-running streams --- .../google/firebase/functions/StramTests.kt | 128 ++++++++++ .../firebase/functions/FirebaseFunctions.kt | 226 ++++++++++++++++++ .../functions/HttpsCallableReference.kt | 83 +++++++ .../firebase/functions/SSETaskListener.kt | 14 ++ 4 files changed, 451 insertions(+) create mode 100644 firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt new file mode 100644 index 00000000000..6f2a2693ec2 --- /dev/null +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt @@ -0,0 +1,128 @@ +package com.google.firebase.functions.ktx + +import androidx.test.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.FirebaseFunctionsException +import com.google.firebase.functions.SSETaskListener +import com.google.firebase.ktx.Firebase +import com.google.firebase.ktx.initialize +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StreamTests { + + private lateinit var app: FirebaseApp + private lateinit var listener: SSETaskListener + + private lateinit var functions: FirebaseFunctions + var onNext = mutableListOf() + var onError: Any? = null + var onComplete: Any? = null + + @Before + fun setup() { + app = Firebase.initialize(InstrumentationRegistry.getContext())!! + functions = FirebaseFunctions.getInstance() + functions.useEmulator("10.0.2.2", 5001) + listener = + object : SSETaskListener { + override fun onNext(event: Any) { + onNext.add(event) + } + + override fun onError(event: Any) { + onError = event + } + + override fun onComplete(event: Any) { + onComplete = event + } + } + } + + @After + fun clear() { + onNext.clear() + onError = null + onComplete = null + } + + @Test + fun testGenStream() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStream") + val httpsCallableResult = Tasks.await(function.stream(input, listener)) + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isEqualTo("hello world this is cool") + assertThat(httpsCallableResult.data).isEqualTo("hello world this is cool") + } + + @Test + fun testGenStreamError() { + val input = hashMapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStreamError").withTimeout(7, TimeUnit.SECONDS) + + try { + Tasks.await(function.stream(input, listener)) + } catch (exception: Exception) { + onError = exception + } + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isInstanceOf(ExecutionException::class.java) + val cause = (onError as ExecutionException).cause + assertThat(cause).isInstanceOf(FirebaseFunctionsException::class.java) + assertThat((cause as FirebaseFunctionsException).message).contains("Socket closed") + assertThat(onComplete).isNull() + } + + @Test + fun testGenStreamNoReturn() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStreamNoReturn") + try { + Tasks.await(function.stream(input, listener), 7, TimeUnit.SECONDS) + } catch (_: Exception) {} + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isNull() + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 3c0e7d6553e..2858c009ce5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -30,7 +30,10 @@ import com.google.firebase.functions.FirebaseFunctionsException.Code.Companion.f import com.google.firebase.functions.FirebaseFunctionsException.Companion.fromResponse import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.BufferedReader import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.MalformedURLException import java.net.URL @@ -311,6 +314,229 @@ internal constructor( return tcs.task } + internal fun stream( + name: String, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + val url = getURL(name) + stream(url, data, options, context, listener) + } + } + + internal fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + stream(url, data, options, context, listener) + } + } + + private fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + context: HttpsCallableContext?, + listener: SSETaskListener + ): Task { + Preconditions.checkNotNull(url, "url cannot be null") + val tcs = TaskCompletionSource() + val callClient = options.apply(client) + callClient.postStream(url, tcs, listener) { applyCommonConfiguration(data, context) } + + return tcs.task + } + + private inline fun OkHttpClient.postStream( + url: URL, + tcs: TaskCompletionSource, + listener: SSETaskListener, + crossinline config: Request.Builder.() -> Unit = {} + ) { + val requestBuilder = Request.Builder().url(url) + requestBuilder.config() + val request = requestBuilder.build() + + val call = newCall(request) + call.enqueue( + object : Callback { + override fun onFailure(ignored: Call, e: IOException) { + val exception: Exception = + if (e is InterruptedIOException) { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED, + null, + e + ) + } else { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.INTERNAL.name, + FirebaseFunctionsException.Code.INTERNAL, + null, + e + ) + } + listener.onError(exception) + tcs.setException(exception) + } + + @Throws(IOException::class) + override fun onResponse(ignored: Call, response: Response) { + try { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream, serializer, listener, tcs) + } else { + val error = + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + listener.onError(error) + tcs.setException(error) + } + } catch (e: FirebaseFunctionsException) { + listener.onError(e) + tcs.setException(e) + } + } + } + ) + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val trimMargin: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + throw FirebaseFunctionsException( + trimMargin, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), + null + ) + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Unexpected Response:\n$text ", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) + } + + private fun Request.Builder.applyCommonConfiguration(data: Any?, context: HttpsCallableContext?) { + val body: MutableMap = HashMap() + val encoded = serializer.encode(data) + body["data"] = encoded + if (context!!.authToken != null) { + header("Authorization", "Bearer " + context.authToken) + } + if (context.instanceIdToken != null) { + header("Firebase-Instance-ID-Token", context.instanceIdToken) + } + if (context.appCheckToken != null) { + header("X-Firebase-AppCheck", context.appCheckToken) + } + header("Accept", "text/event-stream") + val bodyJSON = JSONObject(body) + val contentType = MediaType.parse("application/json") + val requestBody = RequestBody.create(contentType, bodyJSON.toString()) + post(requestBody) + } + + private fun processSSEStream( + inputStream: InputStream, + serializer: Serializer, + listener: SSETaskListener, + tcs: TaskCompletionSource + ) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + reader.lineSequence().forEach { line -> + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach + } + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { listener.onNext(it) } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + throw FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + listener.onComplete(it) + tcs.setResult(HttpsCallableResult(it)) + } + return + } + } + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + throw FirebaseFunctionsException( + "Stream ended unexpectedly without completion.", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + } catch (e: Exception) { + throw FirebaseFunctionsException( + e.message ?: "Error reading stream", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + } + public companion object { /** A task that will be resolved once ProviderInstaller has installed what it needs to. */ private val providerInstalled = TaskCompletionSource() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 90bdb63221b..da8734757d5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -125,6 +125,89 @@ public class HttpsCallableReference { } } + /** + * Streams data to the specified HTTPS endpoint asynchronously. + * + * The data passed into the endpoint can be any of the following types: + * + * * Any primitive type, including `null`, `int`, `long`, `float`, and `boolean`. + * * [String] + * * [List<?>][java.util.List], where the contained objects are also one of these types. + * * [Map<String, ?>][java.util.Map], where the values are also one of these types. + * * [org.json.JSONArray] + * * [org.json.JSONObject] + * * [org.json.JSONObject.NULL] + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * @param data Parameters to pass to the endpoint. + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see org.json.JSONArray + * @see org.json.JSONObject + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(data: Any?, listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, data, options, listener) + } else { + functionsClient.stream(url!!, data, options, listener) + } + } + + /** + * Streams data to the specified HTTPS endpoint asynchronously without arguments. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, null, options, listener) + } else { + functionsClient.stream(url!!, null, options, listener) + } + } + /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. * diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt new file mode 100644 index 00000000000..dffeddfeec2 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt @@ -0,0 +1,14 @@ +package com.google.firebase.functions + +/** Listener for events from a Server-Sent Events stream. */ +public interface SSETaskListener { + + /** Called when a new event is received. */ + public fun onNext(event: Any) + + /** Called when an error occurs. */ + public fun onError(event: Any) + + /** Called when the stream is closed. */ + public fun onComplete(event: Any) +} From 9e13ef7fc0478b01c022439c55e03425346ab7ea Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 16 Dec 2024 13:57:49 -0800 Subject: [PATCH 2/9] Extend Firebase SDK with new APIs to consume streaming callable function response. - Handling the server-sent event (SSE) parsing internally - Providing proper error handling and connection management - Maintaining memory efficiency for long-running streams --- .../androidTest/backend/functions/index.js | 116 ++++++--- .../google/firebase/functions/StramTests.kt | 127 ++++++++++ .../firebase/functions/FirebaseFunctions.kt | 226 ++++++++++++++++++ .../functions/HttpsCallableReference.kt | 83 +++++++ .../firebase/functions/SSETaskListener.kt | 14 ++ 5 files changed, 536 insertions(+), 30 deletions(-) create mode 100644 firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt diff --git a/firebase-functions/src/androidTest/backend/functions/index.js b/firebase-functions/src/androidTest/backend/functions/index.js index fed5a371b89..631945dfa75 100644 --- a/firebase-functions/src/androidTest/backend/functions/index.js +++ b/firebase-functions/src/androidTest/backend/functions/index.js @@ -12,32 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -const assert = require('assert'); -const functions = require('firebase-functions'); +const assert = require("assert"); +const functions = require("firebase-functions"); exports.dataTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, { data: { - bool: true, - int: 2, - long: { - value: '3', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "bool": true, + "int": 2, + "long": { + "value": "3", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, - string: 'four', - array: [5, 6], - 'null': null, - } + "string": "four", + "array": [5, 6], + "null": null, + }, }); response.send({ data: { - message: 'stub response', + message: "stub response", code: 42, long: { - value: '420', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "value": "420", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, - } + }, }); }); @@ -47,28 +47,29 @@ exports.scalarTest = functions.https.onRequest((request, response) => { }); exports.tokenTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('Authorization'), 'Bearer token'); + assert.equal(request.get("Authorization"), "Bearer token"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); exports.instanceIdTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('Firebase-Instance-ID-Token'), 'iid'); + assert.equal(request.get("Firebase-Instance-ID-Token"), "iid"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); exports.appCheckTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck'); + assert.equal(request.get("X-Firebase-AppCheck"), "appCheck"); assert.deepEqual(request.body, {data: {}}); response.send({data: {}}); }); -exports.appCheckLimitedUseTest = functions.https.onRequest((request, response) => { - assert.equal(request.get('X-Firebase-AppCheck'), 'appCheck-limited-use'); - assert.deepEqual(request.body, {data: {}}); - response.send({data: {}}); -}); +exports.appCheckLimitedUseTest = functions.https.onRequest( + (request, response) => { + assert.equal(request.get("X-Firebase-AppCheck"), "appCheck-limited-use"); + assert.deepEqual(request.body, {data: {}}); + response.send({data: {}}); + }); exports.nullTest = functions.https.onRequest((request, response) => { assert.deepEqual(request.body, {data: null}); @@ -82,15 +83,15 @@ exports.missingResultTest = functions.https.onRequest((request, response) => { exports.unhandledErrorTest = functions.https.onRequest((request, response) => { // Fail in a way that the client shouldn't see. - throw 'nope'; + throw new Error("nope"); }); exports.unknownErrorTest = functions.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: 'THIS_IS_NOT_VALID', - message: 'this should be ignored', + status: "THIS_IS_NOT_VALID", + message: "this should be ignored", }, }); }); @@ -99,14 +100,14 @@ exports.explicitErrorTest = functions.https.onRequest((request, response) => { // Send an http error with a body with an explicit code. response.status(400).send({ error: { - status: 'OUT_OF_RANGE', - message: 'explicit nope', + status: "OUT_OF_RANGE", + message: "explicit nope", details: { start: 10, end: 20, long: { - value: '30', - '@type': 'type.googleapis.com/google.protobuf.Int64Value', + "value": "30", + "@type": "type.googleapis.com/google.protobuf.Int64Value", }, }, }, @@ -122,3 +123,58 @@ exports.timeoutTest = functions.https.onRequest((request, response) => { // Wait for longer than 500ms. setTimeout(() => response.send({data: true}), 500); }); + +const data = ["hello", "world", "this", "is", "cool"]; + +/** + * Pauses the execution for a specified amount of time. + * @param {number} ms - The number of milliseconds to sleep. + * @return {Promise} A promise that resolves after the specified time. + */ +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Generates chunks of text asynchronously, yielding one chunk at a time. + * @async + * @generator + * @yields {string} A chunk of text from the data array. + */ +async function* generateText() { + for (const chunk of data) { + yield chunk; + await sleep(1000); + } +} + +exports.genStream = functions.https.onCall(async (request, response) => { + if (response && response.acceptsStreaming) { + for await (const chunk of generateText()) { + console.log("got chunk", chunk); + response.write({chunk}); + } + } + return data.join(" "); +}); + +exports.genStreamError = functions.https.onCall(async (request, response) => { + if (response && response.acceptsStreaming) { + for await (const chunk of generateText()) { + console.log("got chunk", chunk); + response.write({chunk}); + } + throw new Error("BOOM"); + } +}); + +exports.genStreamNoReturn = functions.https.onCall( + async (request, response) => { + if (response && response.acceptsStreaming) { + for await (const chunk of generateText()) { + console.log("got chunk", chunk); + response.write({chunk}); + } + } + }, +); diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt new file mode 100644 index 00000000000..3548c56f05f --- /dev/null +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StramTests.kt @@ -0,0 +1,127 @@ +package com.google.firebase.functions.ktx + +import androidx.test.InstrumentationRegistry +import androidx.test.runner.AndroidJUnit4 +import com.google.android.gms.tasks.Tasks +import com.google.common.truth.Truth.assertThat +import com.google.firebase.FirebaseApp +import com.google.firebase.functions.FirebaseFunctions +import com.google.firebase.functions.FirebaseFunctionsException +import com.google.firebase.functions.SSETaskListener +import com.google.firebase.ktx.Firebase +import com.google.firebase.ktx.initialize +import java.util.concurrent.ExecutionException +import java.util.concurrent.TimeUnit +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StreamTests { + + private lateinit var app: FirebaseApp + private lateinit var listener: SSETaskListener + + private lateinit var functions: FirebaseFunctions + var onNext = mutableListOf() + var onError: Any? = null + var onComplete: Any? = null + + @Before + fun setup() { + app = Firebase.initialize(InstrumentationRegistry.getContext())!! + functions = FirebaseFunctions.getInstance() + listener = + object : SSETaskListener { + override fun onNext(event: Any) { + onNext.add(event) + } + + override fun onError(event: Any) { + onError = event + } + + override fun onComplete(event: Any) { + onComplete = event + } + } + } + + @After + fun clear() { + onNext.clear() + onError = null + onComplete = null + } + + @Test + fun testGenStream() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStream") + val httpsCallableResult = Tasks.await(function.stream(input, listener)) + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isEqualTo("hello world this is cool") + assertThat(httpsCallableResult.data).isEqualTo("hello world this is cool") + } + + @Test + fun testGenStreamError() { + val input = hashMapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStreamError").withTimeout(7, TimeUnit.SECONDS) + + try { + Tasks.await(function.stream(input, listener)) + } catch (exception: Exception) { + onError = exception + } + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isInstanceOf(ExecutionException::class.java) + val cause = (onError as ExecutionException).cause + assertThat(cause).isInstanceOf(FirebaseFunctionsException::class.java) + assertThat((cause as FirebaseFunctionsException).message).contains("stream was reset: CANCEL") + assertThat(onComplete).isNull() + } + + @Test + fun testGenStreamNoReturn() { + val input = hashMapOf("data" to "Why is the sky blue") + + val function = functions.getHttpsCallable("genStreamNoReturn") + try { + Tasks.await(function.stream(input, listener), 7, TimeUnit.SECONDS) + } catch (_: Exception) {} + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + "{chunk=this}", + "{chunk=is}", + "{chunk=cool}" + ) + assertThat(onError).isNull() + assertThat(onComplete).isNull() + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 3c0e7d6553e..2858c009ce5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -30,7 +30,10 @@ import com.google.firebase.functions.FirebaseFunctionsException.Code.Companion.f import com.google.firebase.functions.FirebaseFunctionsException.Companion.fromResponse import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.BufferedReader import java.io.IOException +import java.io.InputStream +import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.MalformedURLException import java.net.URL @@ -311,6 +314,229 @@ internal constructor( return tcs.task } + internal fun stream( + name: String, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + val url = getURL(name) + stream(url, data, options, context, listener) + } + } + + internal fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + listener: SSETaskListener + ): Task { + return providerInstalled.task + .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } + .continueWithTask(executor) { task: Task -> + if (!task.isSuccessful) { + return@continueWithTask Tasks.forException(task.exception!!) + } + val context = task.result + stream(url, data, options, context, listener) + } + } + + private fun stream( + url: URL, + data: Any?, + options: HttpsCallOptions, + context: HttpsCallableContext?, + listener: SSETaskListener + ): Task { + Preconditions.checkNotNull(url, "url cannot be null") + val tcs = TaskCompletionSource() + val callClient = options.apply(client) + callClient.postStream(url, tcs, listener) { applyCommonConfiguration(data, context) } + + return tcs.task + } + + private inline fun OkHttpClient.postStream( + url: URL, + tcs: TaskCompletionSource, + listener: SSETaskListener, + crossinline config: Request.Builder.() -> Unit = {} + ) { + val requestBuilder = Request.Builder().url(url) + requestBuilder.config() + val request = requestBuilder.build() + + val call = newCall(request) + call.enqueue( + object : Callback { + override fun onFailure(ignored: Call, e: IOException) { + val exception: Exception = + if (e is InterruptedIOException) { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, + FirebaseFunctionsException.Code.DEADLINE_EXCEEDED, + null, + e + ) + } else { + FirebaseFunctionsException( + FirebaseFunctionsException.Code.INTERNAL.name, + FirebaseFunctionsException.Code.INTERNAL, + null, + e + ) + } + listener.onError(exception) + tcs.setException(exception) + } + + @Throws(IOException::class) + override fun onResponse(ignored: Call, response: Response) { + try { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream, serializer, listener, tcs) + } else { + val error = + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + listener.onError(error) + tcs.setException(error) + } + } catch (e: FirebaseFunctionsException) { + listener.onError(e) + tcs.setException(e) + } + } + } + ) + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val trimMargin: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() + throw FirebaseFunctionsException( + trimMargin, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), + null + ) + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Unexpected Response:\n$text ", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) + } + + private fun Request.Builder.applyCommonConfiguration(data: Any?, context: HttpsCallableContext?) { + val body: MutableMap = HashMap() + val encoded = serializer.encode(data) + body["data"] = encoded + if (context!!.authToken != null) { + header("Authorization", "Bearer " + context.authToken) + } + if (context.instanceIdToken != null) { + header("Firebase-Instance-ID-Token", context.instanceIdToken) + } + if (context.appCheckToken != null) { + header("X-Firebase-AppCheck", context.appCheckToken) + } + header("Accept", "text/event-stream") + val bodyJSON = JSONObject(body) + val contentType = MediaType.parse("application/json") + val requestBody = RequestBody.create(contentType, bodyJSON.toString()) + post(requestBody) + } + + private fun processSSEStream( + inputStream: InputStream, + serializer: Serializer, + listener: SSETaskListener, + tcs: TaskCompletionSource + ) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + reader.lineSequence().forEach { line -> + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach + } + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { listener.onNext(it) } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + throw FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + listener.onComplete(it) + tcs.setResult(HttpsCallableResult(it)) + } + return + } + } + } catch (e: Throwable) { + throw FirebaseFunctionsException( + "${e.message} Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + throw FirebaseFunctionsException( + "Stream ended unexpectedly without completion.", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + } catch (e: Exception) { + throw FirebaseFunctionsException( + e.message ?: "Error reading stream", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + } + } + } + public companion object { /** A task that will be resolved once ProviderInstaller has installed what it needs to. */ private val providerInstalled = TaskCompletionSource() diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 90bdb63221b..da8734757d5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -125,6 +125,89 @@ public class HttpsCallableReference { } } + /** + * Streams data to the specified HTTPS endpoint asynchronously. + * + * The data passed into the endpoint can be any of the following types: + * + * * Any primitive type, including `null`, `int`, `long`, `float`, and `boolean`. + * * [String] + * * [List<?>][java.util.List], where the contained objects are also one of these types. + * * [Map<String, ?>][java.util.Map], where the values are also one of these types. + * * [org.json.JSONArray] + * * [org.json.JSONObject] + * * [org.json.JSONObject.NULL] + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * @param data Parameters to pass to the endpoint. + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see org.json.JSONArray + * @see org.json.JSONObject + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(data: Any?, listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, data, options, listener) + } else { + functionsClient.stream(url!!, data, options, listener) + } + } + + /** + * Streams data to the specified HTTPS endpoint asynchronously without arguments. + * + * The request to the Cloud Functions backend made by this method automatically includes a + * Firebase Instance ID token to identify the app instance. If a user is logged in with Firebase + * Auth, an auth token for the user will also be automatically included. + * + * Firebase Instance ID sends data to the Firebase backend periodically to collect information + * regarding the app instance. To stop this, see + * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new + * Instance ID the next time you call this method. + * + * Streaming events are handled by the provided [SSETaskListener], which will receive events and + * handle errors and completion notifications. + * + * If the returned task fails, the exception will be one of the following types: + * + * * [java.io.IOException] + * - if the HTTPS request failed to connect. + * * [FirebaseFunctionsException] + * - if the request connected, but the function returned an error. + * + * @param listener A listener to handle streaming events, errors, and completion notifications. + * @return A Task that will be completed when the streaming operation has finished. + * @see java.io.IOException + * @see FirebaseFunctionsException + */ + public fun stream(listener: SSETaskListener): Task { + return if (name != null) { + functionsClient.stream(name, null, options, listener) + } else { + functionsClient.stream(url!!, null, options, listener) + } + } + /** * Changes the timeout for calls from this instance of Functions. The default is 60 seconds. * diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt new file mode 100644 index 00000000000..dffeddfeec2 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt @@ -0,0 +1,14 @@ +package com.google.firebase.functions + +/** Listener for events from a Server-Sent Events stream. */ +public interface SSETaskListener { + + /** Called when a new event is received. */ + public fun onNext(event: Any) + + /** Called when an error occurs. */ + public fun onError(event: Any) + + /** Called when the stream is closed. */ + public fun onComplete(event: Any) +} From 1fa85a625db84280faa41922b9dfdf5c2e9bcc7a Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Fri, 27 Dec 2024 11:30:05 -0800 Subject: [PATCH 3/9] Update the SSETaskListener implementation to conform to the org.reactivestreams.Subscriber interface. --- .../com/google/firebase/functions/StreamTests.kt | 12 ++++++------ .../google/firebase/functions/FirebaseFunctions.kt | 14 +++++++------- .../google/firebase/functions/SSETaskListener.kt | 6 +++--- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 3548c56f05f..c66e15a7d4e 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -34,16 +34,16 @@ class StreamTests { functions = FirebaseFunctions.getInstance() listener = object : SSETaskListener { - override fun onNext(event: Any) { - onNext.add(event) + override fun onNext(message: Any) { + onNext.add(message) } - override fun onError(event: Any) { - onError = event + override fun onError(exception: FirebaseFunctionsException) { + onError = exception } - override fun onComplete(event: Any) { - onComplete = event + override fun onComplete(result: Any) { + onComplete = result } } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 2858c009ce5..a1e244eaf8d 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -378,7 +378,7 @@ internal constructor( call.enqueue( object : Callback { override fun onFailure(ignored: Call, e: IOException) { - val exception: Exception = + val exception: FirebaseFunctionsException = if (e is InterruptedIOException) { FirebaseFunctionsException( FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, @@ -406,18 +406,18 @@ internal constructor( if (bodyStream != null) { processSSEStream(bodyStream, serializer, listener, tcs) } else { - val error = + val exception = FirebaseFunctionsException( "Response body is null", FirebaseFunctionsException.Code.INTERNAL, null ) - listener.onError(error) - tcs.setException(error) + listener.onError(exception) + tcs.setException(exception) } - } catch (e: FirebaseFunctionsException) { - listener.onError(e) - tcs.setException(e) + } catch (exception: FirebaseFunctionsException) { + listener.onError(exception) + tcs.setException(exception) } } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt index dffeddfeec2..85d21c7f1df 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt @@ -4,11 +4,11 @@ package com.google.firebase.functions public interface SSETaskListener { /** Called when a new event is received. */ - public fun onNext(event: Any) + public fun onNext(message: Any) /** Called when an error occurs. */ - public fun onError(event: Any) + public fun onError(exception: FirebaseFunctionsException) /** Called when the stream is closed. */ - public fun onComplete(event: Any) + public fun onComplete(result: Any) } From 7034537cea73bf5b3c62a2c301b98f1671405b16 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 29 Jan 2025 21:04:26 -0800 Subject: [PATCH 4/9] Refactor Stream Listener Implementation --- firebase-functions/api.txt | 27 ++++ .../google/firebase/functions/StreamTests.kt | 105 ++++++++------- .../firebase/functions/FirebaseFunctions.kt | 91 ++++++------- .../functions/HttpsCallableReference.kt | 33 ++--- .../firebase/functions/SSETaskListener.kt | 14 -- .../firebase/functions/StreamFunctionsTask.kt | 121 ++++++++++++++++++ .../firebase/functions/StreamListener.kt | 8 ++ 7 files changed, 269 insertions(+), 130 deletions(-) delete mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt create mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index 2963a38621f..2de8f1a358b 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -86,6 +86,7 @@ package com.google.firebase.functions { method @NonNull public com.google.android.gms.tasks.Task call(); method public long getTimeout(); method public void setTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); + method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(@Nullable Object data = null); method @NonNull public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); property public final long timeout; } @@ -95,6 +96,32 @@ package com.google.firebase.functions { field @Nullable public final Object data; } + public final class StreamFunctionsTask extends com.google.android.gms.tasks.Task { + ctor public StreamFunctionsTask(); + method @NonNull public com.google.firebase.functions.StreamFunctionsTask addOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); + method public void removeOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); + method public void notifyListeners(@NonNull Object data); + method public void complete(@NonNull com.google.firebase.functions.HttpsCallableResult result); + method public void fail(@NonNull Exception exception); + method @Nullable public Exception getException(); + method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(); + method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(@NonNull Class p0); + method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull com.google.android.gms.tasks.OnFailureListener p0); + method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); + method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull com.google.android.gms.tasks.OnSuccessListener p0); + method public boolean isCanceled(); + method public boolean isComplete(); + method public boolean isSuccessful(); + method public void cancel(); + } + + public fun interface StreamListener { + method public void onNext(@NonNull Object message); + } + } package com.google.firebase.functions.ktx { diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index c66e15a7d4e..c3a881a6330 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -1,16 +1,15 @@ package com.google.firebase.functions.ktx -import androidx.test.InstrumentationRegistry -import androidx.test.runner.AndroidJUnit4 -import com.google.android.gms.tasks.Tasks +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.google.firebase.FirebaseApp +import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctionsException -import com.google.firebase.functions.SSETaskListener -import com.google.firebase.ktx.Firebase -import com.google.firebase.ktx.initialize -import java.util.concurrent.ExecutionException +import com.google.firebase.functions.StreamFunctionsTask +import com.google.firebase.functions.StreamListener +import com.google.firebase.functions.functions +import com.google.firebase.initialize import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Before @@ -20,48 +19,35 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class StreamTests { - private lateinit var app: FirebaseApp - private lateinit var listener: SSETaskListener - + private lateinit var listener: StreamListener private lateinit var functions: FirebaseFunctions var onNext = mutableListOf() - var onError: Any? = null - var onComplete: Any? = null @Before fun setup() { - app = Firebase.initialize(InstrumentationRegistry.getContext())!! - functions = FirebaseFunctions.getInstance() + Firebase.initialize(ApplicationProvider.getApplicationContext()) + functions = Firebase.functions listener = - object : SSETaskListener { + object : StreamListener { override fun onNext(message: Any) { onNext.add(message) } - - override fun onError(exception: FirebaseFunctionsException) { - onError = exception - } - - override fun onComplete(result: Any) { - onComplete = result - } } } @After fun clear() { onNext.clear() - onError = null - onComplete = null } @Test fun testGenStream() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStream") - val httpsCallableResult = Tasks.await(function.stream(input, listener)) + val task = function.stream(input).addOnStreamListener(listener) + + Thread.sleep(6000) val onNextStringList = onNext.map { it.toString() } assertThat(onNextStringList) .containsExactly( @@ -71,21 +57,19 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(onError).isNull() - assertThat(onComplete).isEqualTo("hello world this is cool") - assertThat(httpsCallableResult.data).isEqualTo("hello world this is cool") + assertThat(task.result.data).isEqualTo("hello world this is cool") } @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamError").withTimeout(7, TimeUnit.SECONDS) + val function = functions.getHttpsCallable("genStreamError").withTimeout(6, TimeUnit.SECONDS) + var task: StreamFunctionsTask? = null try { - Tasks.await(function.stream(input, listener)) - } catch (exception: Exception) { - onError = exception - } + task = function.stream(input).addOnStreamListener(listener) + } catch (_: Throwable) {} + Thread.sleep(7000) val onNextStringList = onNext.map { it.toString() } assertThat(onNextStringList) @@ -96,21 +80,18 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(onError).isInstanceOf(ExecutionException::class.java) - val cause = (onError as ExecutionException).cause - assertThat(cause).isInstanceOf(FirebaseFunctionsException::class.java) - assertThat((cause as FirebaseFunctionsException).message).contains("stream was reset: CANCEL") - assertThat(onComplete).isNull() + assertThat(requireNotNull(task).isSuccessful).isFalse() + assertThat(task.exception).isInstanceOf(FirebaseFunctionsException::class.java) + assertThat(requireNotNull(task.exception).message).contains("stream was reset: CANCEL") } @Test fun testGenStreamNoReturn() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamNoReturn") - try { - Tasks.await(function.stream(input, listener), 7, TimeUnit.SECONDS) - } catch (_: Exception) {} + + val task = function.stream(input).addOnStreamListener(listener) + Thread.sleep(7000) val onNextStringList = onNext.map { it.toString() } assertThat(onNextStringList) @@ -121,7 +102,37 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(onError).isNull() - assertThat(onComplete).isNull() + try { + task.result + } catch (e: Throwable) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("No result available.") + } + } + + @Test + fun testGenStream_cancelStream() { + val input = hashMapOf("data" to "Why is the sky blue") + val function = functions.getHttpsCallable("genStreamNoReturn") + val task = function.stream(input).addOnStreamListener(listener) + Thread.sleep(2000) + + task.cancel() + + val onNextStringList = onNext.map { it.toString() } + assertThat(onNextStringList) + .containsExactly( + "{chunk=hello}", + "{chunk=world}", + ) + try { + task.result + } catch (e: Throwable) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("No result available.") + } + assertThat(task.isCanceled).isTrue() + assertThat(task.isComplete).isFalse() + assertThat(task.isSuccessful).isFalse() } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index e9813b7747a..f9da8dfabe5 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -314,39 +314,51 @@ internal constructor( return tcs.task } - internal fun stream( - name: String, - data: Any?, - options: HttpsCallOptions, - listener: SSETaskListener - ): Task { - return providerInstalled.task + internal fun stream(name: String, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { + val task = StreamFunctionsTask() + providerInstalled.task .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .continueWithTask(executor) { task: Task -> - if (!task.isSuccessful) { - return@continueWithTask Tasks.forException(task.exception!!) + .addOnCompleteListener(executor) { contextTask -> + if (!contextTask.isSuccessful) { + task.fail( + FirebaseFunctionsException( + "Error retrieving context", + FirebaseFunctionsException.Code.INTERNAL, + null, + contextTask.exception + ) + ) + return@addOnCompleteListener } - val context = task.result val url = getURL(name) - stream(url, data, options, context, listener) + stream(url, data, options, contextTask.result, task) } + + return task } - internal fun stream( - url: URL, - data: Any?, - options: HttpsCallOptions, - listener: SSETaskListener - ): Task { - return providerInstalled.task + internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { + val task = StreamFunctionsTask() + providerInstalled.task .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .continueWithTask(executor) { task: Task -> - if (!task.isSuccessful) { - return@continueWithTask Tasks.forException(task.exception!!) + .addOnCompleteListener(executor) { contextTask -> + if (!contextTask.isSuccessful) { + task.fail( + FirebaseFunctionsException( + "Error retrieving context", + FirebaseFunctionsException.Code.INTERNAL, + null, + contextTask.exception + ) + ) + + return@addOnCompleteListener } - val context = task.result - stream(url, data, options, context, listener) + + stream(url, data, options, contextTask.result, task) } + + return task } private fun stream( @@ -354,20 +366,16 @@ internal constructor( data: Any?, options: HttpsCallOptions, context: HttpsCallableContext?, - listener: SSETaskListener - ): Task { + task: StreamFunctionsTask + ) { Preconditions.checkNotNull(url, "url cannot be null") - val tcs = TaskCompletionSource() val callClient = options.apply(client) - callClient.postStream(url, tcs, listener) { applyCommonConfiguration(data, context) } - - return tcs.task + callClient.postStream(url, task) { applyCommonConfiguration(data, context) } } private inline fun OkHttpClient.postStream( url: URL, - tcs: TaskCompletionSource, - listener: SSETaskListener, + task: StreamFunctionsTask, crossinline config: Request.Builder.() -> Unit = {} ) { val requestBuilder = Request.Builder().url(url) @@ -394,8 +402,7 @@ internal constructor( e ) } - listener.onError(exception) - tcs.setException(exception) + task.fail(exception) } @Throws(IOException::class) @@ -404,7 +411,7 @@ internal constructor( validateResponse(response) val bodyStream = response.body()?.byteStream() if (bodyStream != null) { - processSSEStream(bodyStream, serializer, listener, tcs) + processSSEStream(bodyStream, serializer, task) } else { val exception = FirebaseFunctionsException( @@ -412,12 +419,10 @@ internal constructor( FirebaseFunctionsException.Code.INTERNAL, null ) - listener.onError(exception) - tcs.setException(exception) + task.fail(exception) } } catch (exception: FirebaseFunctionsException) { - listener.onError(exception) - tcs.setException(exception) + task.fail(exception) } } } @@ -480,8 +485,7 @@ internal constructor( private fun processSSEStream( inputStream: InputStream, serializer: Serializer, - listener: SSETaskListener, - tcs: TaskCompletionSource + task: StreamFunctionsTask ) { BufferedReader(InputStreamReader(inputStream)).use { reader -> try { @@ -496,7 +500,7 @@ internal constructor( val json = JSONObject(dataChunk) when { json.has("message") -> - serializer.decode(json.opt("message"))?.let { listener.onNext(it) } + serializer.decode(json.opt("message"))?.let { task.notifyListeners(it) } json.has("error") -> { serializer.decode(json.opt("error"))?.let { throw FirebaseFunctionsException( @@ -508,8 +512,7 @@ internal constructor( } json.has("result") -> { serializer.decode(json.opt("result"))?.let { - listener.onComplete(it) - tcs.setResult(HttpsCallableResult(it)) + task.complete(HttpsCallableResult(it)) } return } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index 08b1bf901f6..a18849f619f 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -154,22 +154,18 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * Streaming events are handled by the provided [SSETaskListener], which will receive events and - * handle errors and completion notifications. - * * @param data Parameters to pass to the endpoint. - * @param listener A listener to handle streaming events, errors, and completion notifications. - * @return A Task that will be completed when the streaming operation has finished. + * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. * @see org.json.JSONArray * @see org.json.JSONObject * @see java.io.IOException * @see FirebaseFunctionsException */ - public fun stream(data: Any?, listener: SSETaskListener): Task { + public fun stream(data: Any?): StreamFunctionsTask { return if (name != null) { - functionsClient.stream(name, data, options, listener) + functionsClient.stream(name, data, options) } else { - functionsClient.stream(url!!, data, options, listener) + functionsClient.stream(requireNotNull(url), data, options) } } @@ -185,26 +181,13 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * Streaming events are handled by the provided [SSETaskListener], which will receive events and - * handle errors and completion notifications. - * - * If the returned task fails, the exception will be one of the following types: - * - * * [java.io.IOException] - * - if the HTTPS request failed to connect. - * * [FirebaseFunctionsException] - * - if the request connected, but the function returned an error. - * - * @param listener A listener to handle streaming events, errors, and completion notifications. - * @return A Task that will be completed when the streaming operation has finished. - * @see java.io.IOException - * @see FirebaseFunctionsException + * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. */ - public fun stream(listener: SSETaskListener): Task { + public fun stream(): StreamFunctionsTask { return if (name != null) { - functionsClient.stream(name, null, options, listener) + functionsClient.stream(name, null, options) } else { - functionsClient.stream(url!!, null, options, listener) + functionsClient.stream(requireNotNull(url), null, options) } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt deleted file mode 100644 index 85d21c7f1df..00000000000 --- a/firebase-functions/src/main/java/com/google/firebase/functions/SSETaskListener.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.google.firebase.functions - -/** Listener for events from a Server-Sent Events stream. */ -public interface SSETaskListener { - - /** Called when a new event is received. */ - public fun onNext(message: Any) - - /** Called when an error occurs. */ - public fun onError(exception: FirebaseFunctionsException) - - /** Called when the stream is closed. */ - public fun onComplete(result: Any) -} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt new file mode 100644 index 00000000000..3c206022838 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt @@ -0,0 +1,121 @@ +package com.google.firebase.functions + +import android.app.Activity +import com.google.android.gms.tasks.OnFailureListener +import com.google.android.gms.tasks.OnSuccessListener +import com.google.android.gms.tasks.Task +import java.util.Queue +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.Executor + +public class StreamFunctionsTask : Task() { + + private val listenerQueue: Queue = ConcurrentLinkedQueue() + private var result: HttpsCallableResult? = null + private var exception: Exception? = null + private var isComplete: Boolean = false + private var isCanceled = false + + public fun addOnStreamListener(listener: StreamListener): StreamFunctionsTask { + listenerQueue.add(listener) + return this + } + + public fun removeOnStreamListener(listener: StreamListener) { + listenerQueue.remove(listener) + } + + internal fun notifyListeners(data: Any) { + for (listener in listenerQueue) { + listener.onNext(data) + } + } + + internal fun complete(result: HttpsCallableResult) { + this.result = result + this.isComplete = true + } + + internal fun fail(exception: Exception) { + this.exception = exception + this.isComplete = true + } + + override fun getException(): Exception? { + listenerQueue.clear() + return exception + } + + override fun getResult(): HttpsCallableResult { + listenerQueue.clear() + return result ?: throw IllegalStateException("No result available.") + } + + override fun getResult(p0: Class): HttpsCallableResult { + if (p0.isInstance(exception)) { + throw p0.cast(exception)!! + } + return getResult() + } + + override fun addOnFailureListener(listener: OnFailureListener): Task { + if (exception != null) listener.onFailure(requireNotNull(exception)) + return this + } + + override fun addOnFailureListener( + activity: Activity, + listener: OnFailureListener + ): Task { + if (exception != null) listener.onFailure(requireNotNull(exception)) + return this + } + + override fun addOnFailureListener( + executor: Executor, + listener: OnFailureListener + ): Task { + if (exception != null) executor.execute { listener.onFailure(requireNotNull(exception)) } + return this + } + + override fun addOnSuccessListener( + executor: Executor, + listener: OnSuccessListener + ): Task { + if (result != null) executor.execute { listener.onSuccess(requireNotNull(result)) } + return this + } + + override fun addOnSuccessListener( + activity: Activity, + listener: OnSuccessListener + ): Task { + if (result != null) listener.onSuccess(requireNotNull(result)) + return this + } + + override fun addOnSuccessListener( + listener: OnSuccessListener + ): Task { + if (result != null) listener.onSuccess(requireNotNull(result)) + return this + } + + override fun isCanceled(): Boolean { + return isCanceled + } + + override fun isComplete(): Boolean { + return isComplete + } + + override fun isSuccessful(): Boolean { + return exception == null && result != null + } + + public fun cancel() { + isCanceled = true + listenerQueue.clear() + } +} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt new file mode 100644 index 00000000000..539c55be3a4 --- /dev/null +++ b/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt @@ -0,0 +1,8 @@ +package com.google.firebase.functions + +/** Listener for events from a Server-Sent Events stream. */ +public interface StreamListener { + + /** Called when a new event is received. */ + public fun onNext(message: Any) +} From 7f0382b7e4b63e4850d0026e5dd3bd1b658c16af Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Wed, 29 Jan 2025 22:38:32 -0800 Subject: [PATCH 5/9] Fix test cases on StreamTests. --- .../google/firebase/functions/StreamTests.kt | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index c3a881a6330..97f9831b2ce 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -5,7 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions -import com.google.firebase.functions.FirebaseFunctionsException import com.google.firebase.functions.StreamFunctionsTask import com.google.firebase.functions.StreamListener import com.google.firebase.functions.functions @@ -58,17 +57,15 @@ class StreamTests { "{chunk=cool}" ) assertThat(task.result.data).isEqualTo("hello world this is cool") + task.removeOnStreamListener(listener) } @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamError").withTimeout(6, TimeUnit.SECONDS) - var task: StreamFunctionsTask? = null - try { - task = function.stream(input).addOnStreamListener(listener) - } catch (_: Throwable) {} + val task: StreamFunctionsTask = function.stream(input).addOnStreamListener(listener) Thread.sleep(7000) val onNextStringList = onNext.map { it.toString() } @@ -80,9 +77,15 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - assertThat(requireNotNull(task).isSuccessful).isFalse() - assertThat(task.exception).isInstanceOf(FirebaseFunctionsException::class.java) - assertThat(requireNotNull(task.exception).message).contains("stream was reset: CANCEL") + + assertThat(task.isSuccessful).isFalse() + try { + assertThat(task.result).isNull() + } catch (e: Throwable) { + assertThat(e).isInstanceOf(IllegalStateException::class.java) + assertThat(e.message).isEqualTo("No result available.") + } + task.removeOnStreamListener(listener) } @Test @@ -103,11 +106,12 @@ class StreamTests { "{chunk=cool}" ) try { - task.result + assertThat(task.result).isNull() } catch (e: Throwable) { assertThat(e).isInstanceOf(IllegalStateException::class.java) assertThat(e.message).isEqualTo("No result available.") } + task.removeOnStreamListener(listener) } @Test @@ -125,14 +129,9 @@ class StreamTests { "{chunk=hello}", "{chunk=world}", ) - try { - task.result - } catch (e: Throwable) { - assertThat(e).isInstanceOf(IllegalStateException::class.java) - assertThat(e.message).isEqualTo("No result available.") - } assertThat(task.isCanceled).isTrue() assertThat(task.isComplete).isFalse() assertThat(task.isSuccessful).isFalse() + task.removeOnStreamListener(listener) } } From e0099601aefd86ad7d27d99192fc36fe2b1ce9f9 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 16:57:08 -0800 Subject: [PATCH 6/9] Optimize streaming by introducing reactive.streams --- .../firebase-functions.gradle.kts | 2 + .../google/firebase/functions/StreamTests.kt | 122 +++--- .../firebase/functions/FirebaseFunctions.kt | 385 ++++++++++-------- .../functions/HttpsCallableReference.kt | 9 +- 4 files changed, 286 insertions(+), 232 deletions(-) diff --git a/firebase-functions/firebase-functions.gradle.kts b/firebase-functions/firebase-functions.gradle.kts index 7ec958bdd79..aee9b06c24b 100644 --- a/firebase-functions/firebase-functions.gradle.kts +++ b/firebase-functions/firebase-functions.gradle.kts @@ -112,6 +112,8 @@ dependencies { implementation(libs.okhttp) implementation(libs.playservices.base) implementation(libs.playservices.basement) + implementation(libs.reactive.streams) + api(libs.playservices.tasks) kapt(libs.autovalue) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index 97f9831b2ce..a1a5efbf569 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -5,8 +5,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.google.firebase.Firebase import com.google.firebase.functions.FirebaseFunctions -import com.google.firebase.functions.StreamFunctionsTask -import com.google.firebase.functions.StreamListener import com.google.firebase.functions.functions import com.google.firebase.initialize import java.util.concurrent.TimeUnit @@ -14,29 +12,47 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription @RunWith(AndroidJUnit4::class) class StreamTests { - private lateinit var listener: StreamListener private lateinit var functions: FirebaseFunctions - var onNext = mutableListOf() + var onNextList = mutableListOf() + private lateinit var subscriber: Subscriber + private var throwable: Throwable? = null + private var isComplete = false @Before fun setup() { Firebase.initialize(ApplicationProvider.getApplicationContext()) functions = Firebase.functions - listener = - object : StreamListener { - override fun onNext(message: Any) { - onNext.add(message) + subscriber = + object : Subscriber { + override fun onSubscribe(subscription: Subscription?) { + subscription?.request(1) + } + + override fun onNext(t: Any) { + onNextList.add(t) + } + + override fun onError(t: Throwable?) { + throwable = t + } + + override fun onComplete() { + isComplete = true } } } @After fun clear() { - onNext.clear() + onNextList.clear() + throwable = null + isComplete = false } @Test @@ -44,48 +60,39 @@ class StreamTests { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStream") - val task = function.stream(input).addOnStreamListener(listener) + function.stream(input).subscribe(subscriber) - Thread.sleep(6000) - val onNextStringList = onNext.map { it.toString() } + Thread.sleep(8000) + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", "{chunk=world}", "{chunk=this}", "{chunk=is}", - "{chunk=cool}" + "{chunk=cool}", + "hello world this is cool" ) - assertThat(task.result.data).isEqualTo("hello world this is cool") - task.removeOnStreamListener(listener) + assertThat(throwable).isNull() + assertThat(isComplete).isTrue() } @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamError").withTimeout(6, TimeUnit.SECONDS) + val function = functions.getHttpsCallable("genStreamError").withTimeout(1, TimeUnit.SECONDS) - val task: StreamFunctionsTask = function.stream(input).addOnStreamListener(listener) - Thread.sleep(7000) + function.stream(input).subscribe(subscriber) + Thread.sleep(8000) - val onNextStringList = onNext.map { it.toString() } + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", - "{chunk=world}", - "{chunk=this}", - "{chunk=is}", - "{chunk=cool}" ) - - assertThat(task.isSuccessful).isFalse() - try { - assertThat(task.result).isNull() - } catch (e: Throwable) { - assertThat(e).isInstanceOf(IllegalStateException::class.java) - assertThat(e.message).isEqualTo("No result available.") - } - task.removeOnStreamListener(listener) + assertThat(throwable).isNotNull() + assertThat(requireNotNull(throwable).message).isEqualTo("timeout") + assertThat(isComplete).isFalse() } @Test @@ -93,10 +100,10 @@ class StreamTests { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") - val task = function.stream(input).addOnStreamListener(listener) - Thread.sleep(7000) + function.stream(input).subscribe(subscriber) + Thread.sleep(8000) - val onNextStringList = onNext.map { it.toString() } + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", @@ -105,33 +112,48 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) - try { - assertThat(task.result).isNull() - } catch (e: Throwable) { - assertThat(e).isInstanceOf(IllegalStateException::class.java) - assertThat(e.message).isEqualTo("No result available.") - } - task.removeOnStreamListener(listener) + assertThat(isComplete).isFalse() } @Test fun testGenStream_cancelStream() { val input = hashMapOf("data" to "Why is the sky blue") val function = functions.getHttpsCallable("genStreamNoReturn") - val task = function.stream(input).addOnStreamListener(listener) - Thread.sleep(2000) + val publisher = function.stream(input) + var subscription: Subscription? = null + val cancelableSubscriber = + object : Subscriber { + override fun onSubscribe(s: Subscription?) { + subscription = s + s?.request(1) + } - task.cancel() + override fun onNext(message: Any) { + onNextList.add(message) + } + + override fun onError(t: Throwable?) { + throwable = t + } + + override fun onComplete() { + isComplete = true + } + } + + publisher.subscribe(cancelableSubscriber) + Thread.sleep(2000) + subscription?.cancel() + Thread.sleep(6000) - val onNextStringList = onNext.map { it.toString() } + val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) .containsExactly( "{chunk=hello}", "{chunk=world}", ) - assertThat(task.isCanceled).isTrue() - assertThat(task.isComplete).isFalse() - assertThat(task.isSuccessful).isFalse() - task.removeOnStreamListener(listener) + assertThat(throwable).isNotNull() + assertThat(requireNotNull(throwable).message).isEqualTo("Stream was canceled") + assertThat(isComplete).isFalse() } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index f9da8dfabe5..2bbc904372b 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -37,6 +37,7 @@ import java.io.InputStreamReader import java.io.InterruptedIOException import java.net.MalformedURLException import java.net.URL +import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executor import javax.inject.Named import okhttp3.Call @@ -48,6 +49,9 @@ import okhttp3.RequestBody import okhttp3.Response import org.json.JSONException import org.json.JSONObject +import org.reactivestreams.Publisher +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription /** FirebaseFunctions lets you call Cloud Functions for Firebase. */ public class FirebaseFunctions @@ -314,36 +318,57 @@ internal constructor( return tcs.task } - internal fun stream(name: String, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { - val task = StreamFunctionsTask() - providerInstalled.task - .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .addOnCompleteListener(executor) { contextTask -> - if (!contextTask.isSuccessful) { - task.fail( - FirebaseFunctionsException( - "Error retrieving context", - FirebaseFunctionsException.Code.INTERNAL, - null, - contextTask.exception - ) - ) - return@addOnCompleteListener - } - val url = getURL(name) - stream(url, data, options, contextTask.result, task) + internal fun stream(name: String, data: Any?, options: HttpsCallOptions): Publisher { + val task = + providerInstalled.task.continueWithTask(executor) { + contextProvider.getContext(options.limitedUseAppCheckTokens) } - return task + return PublisherStream(getURL(name), data, options, client, serializer, task, executor) } - internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): StreamFunctionsTask { - val task = StreamFunctionsTask() - providerInstalled.task - .continueWithTask(executor) { contextProvider.getContext(options.limitedUseAppCheckTokens) } - .addOnCompleteListener(executor) { contextTask -> + internal fun stream(url: URL, data: Any?, options: HttpsCallOptions): Publisher { + val task = + providerInstalled.task.continueWithTask(executor) { + contextProvider.getContext(options.limitedUseAppCheckTokens) + } + + return PublisherStream(url, data, options, client, this.serializer, task, executor) + } + + internal class PublisherStream( + private val url: URL, + private val data: Any?, + private val options: HttpsCallOptions, + private val client: OkHttpClient, + private val serializer: Serializer, + private val contextTask: Task, + private val executor: Executor + ) : Publisher { + + private val subscribers = ConcurrentLinkedQueue>() + private var activeCall: Call? = null + + override fun subscribe(subscriber: Subscriber) { + subscribers.add(subscriber) + subscriber.onSubscribe( + object : Subscription { + override fun request(n: Long) { + startStreaming() + } + + override fun cancel() { + cancelStream() + subscribers.remove(subscriber) + } + } + ) + } + + private fun startStreaming() { + contextTask.addOnCompleteListener(executor) { contextTask -> if (!contextTask.isSuccessful) { - task.fail( + notifyError( FirebaseFunctionsException( "Error retrieving context", FirebaseFunctionsException.Code.INTERNAL, @@ -351,192 +376,196 @@ internal constructor( contextTask.exception ) ) - return@addOnCompleteListener } - stream(url, data, options, contextTask.result, task) - } - - return task - } - - private fun stream( - url: URL, - data: Any?, - options: HttpsCallOptions, - context: HttpsCallableContext?, - task: StreamFunctionsTask - ) { - Preconditions.checkNotNull(url, "url cannot be null") - val callClient = options.apply(client) - callClient.postStream(url, task) { applyCommonConfiguration(data, context) } - } + Preconditions.checkNotNull(url, "url cannot be null") + val context = contextTask.result + val callClient = options.apply(client) + val requestBody = + RequestBody.create( + MediaType.parse("application/json"), + JSONObject(mapOf("data" to serializer.encode(data))).toString() + ) - private inline fun OkHttpClient.postStream( - url: URL, - task: StreamFunctionsTask, - crossinline config: Request.Builder.() -> Unit = {} - ) { - val requestBuilder = Request.Builder().url(url) - requestBuilder.config() - val request = requestBuilder.build() - - val call = newCall(request) - call.enqueue( - object : Callback { - override fun onFailure(ignored: Call, e: IOException) { - val exception: FirebaseFunctionsException = - if (e is InterruptedIOException) { - FirebaseFunctionsException( - FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name, - FirebaseFunctionsException.Code.DEADLINE_EXCEEDED, - null, - e - ) - } else { - FirebaseFunctionsException( - FirebaseFunctionsException.Code.INTERNAL.name, - FirebaseFunctionsException.Code.INTERNAL, - null, - e - ) + val requestBuilder = + Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") + + applyCommonConfiguration(requestBuilder, context) + + val request = requestBuilder.build() + val call = callClient.newCall(request) + activeCall = call + + call.enqueue( + object : Callback { + override fun onFailure(call: Call, e: IOException) { + val message: String + val code: FirebaseFunctionsException.Code + if (e is InterruptedIOException) { + message = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED.name + code = FirebaseFunctionsException.Code.DEADLINE_EXCEEDED + } else { + message = FirebaseFunctionsException.Code.INTERNAL.name + code = FirebaseFunctionsException.Code.INTERNAL + } + notifyError(FirebaseFunctionsException(message, code, null, e)) } - task.fail(exception) - } - @Throws(IOException::class) - override fun onResponse(ignored: Call, response: Response) { - try { - validateResponse(response) - val bodyStream = response.body()?.byteStream() - if (bodyStream != null) { - processSSEStream(bodyStream, serializer, task) - } else { - val exception = - FirebaseFunctionsException( - "Response body is null", - FirebaseFunctionsException.Code.INTERNAL, - null + override fun onResponse(call: Call, response: Response) { + validateResponse(response) + val bodyStream = response.body()?.byteStream() + if (bodyStream != null) { + processSSEStream(bodyStream) + } else { + notifyError( + FirebaseFunctionsException( + "Response body is null", + FirebaseFunctionsException.Code.INTERNAL, + null + ) ) - task.fail(exception) + } } - } catch (exception: FirebaseFunctionsException) { - task.fail(exception) } - } + ) } - ) - } - - private fun validateResponse(response: Response) { - if (response.isSuccessful) return - - val htmlContentType = "text/html; charset=utf-8" - val trimMargin: String - if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { - trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() - throw FirebaseFunctionsException( - trimMargin, - FirebaseFunctionsException.Code.fromHttpStatus(response.code()), - null - ) } - val text = response.body()?.string() ?: "" - val error: Any? - try { - val json = JSONObject(text) - error = serializer.decode(json.opt("error")) - } catch (e: Throwable) { - throw FirebaseFunctionsException( - "${e.message} Unexpected Response:\n$text ", - FirebaseFunctionsException.Code.INTERNAL, - e + private fun cancelStream() { + activeCall?.cancel() + notifyError( + FirebaseFunctionsException( + "Stream was canceled", + FirebaseFunctionsException.Code.CANCELLED, + null + ) ) } - throw FirebaseFunctionsException( - error.toString(), - FirebaseFunctionsException.Code.INTERNAL, - error - ) - } - private fun Request.Builder.applyCommonConfiguration(data: Any?, context: HttpsCallableContext?) { - val body: MutableMap = HashMap() - val encoded = serializer.encode(data) - body["data"] = encoded - if (context!!.authToken != null) { - header("Authorization", "Bearer " + context.authToken) + private fun applyCommonConfiguration( + requestBuilder: Request.Builder, + context: HttpsCallableContext? + ) { + context?.authToken?.let { requestBuilder.header("Authorization", "Bearer $it") } + context?.instanceIdToken?.let { requestBuilder.header("Firebase-Instance-ID-Token", it) } + context?.appCheckToken?.let { requestBuilder.header("X-Firebase-AppCheck", it) } } - if (context.instanceIdToken != null) { - header("Firebase-Instance-ID-Token", context.instanceIdToken) - } - if (context.appCheckToken != null) { - header("X-Firebase-AppCheck", context.appCheckToken) - } - header("Accept", "text/event-stream") - val bodyJSON = JSONObject(body) - val contentType = MediaType.parse("application/json") - val requestBody = RequestBody.create(contentType, bodyJSON.toString()) - post(requestBody) - } - private fun processSSEStream( - inputStream: InputStream, - serializer: Serializer, - task: StreamFunctionsTask - ) { - BufferedReader(InputStreamReader(inputStream)).use { reader -> - try { - reader.lineSequence().forEach { line -> - val dataChunk = - when { - line.startsWith("data:") -> line.removePrefix("data:") - line.startsWith("result:") -> line.removePrefix("result:") - else -> return@forEach - } - try { - val json = JSONObject(dataChunk) - when { - json.has("message") -> - serializer.decode(json.opt("message"))?.let { task.notifyListeners(it) } - json.has("error") -> { - serializer.decode(json.opt("error"))?.let { - throw FirebaseFunctionsException( - it.toString(), - FirebaseFunctionsException.Code.INTERNAL, - it - ) - } + private fun processSSEStream(inputStream: InputStream) { + BufferedReader(InputStreamReader(inputStream)).use { reader -> + try { + reader.lineSequence().forEach { line -> + val dataChunk = + when { + line.startsWith("data:") -> line.removePrefix("data:") + line.startsWith("result:") -> line.removePrefix("result:") + else -> return@forEach } - json.has("result") -> { - serializer.decode(json.opt("result"))?.let { - task.complete(HttpsCallableResult(it)) + try { + val json = JSONObject(dataChunk) + when { + json.has("message") -> + serializer.decode(json.opt("message"))?.let { notifyData(it) } + json.has("error") -> { + serializer.decode(json.opt("error"))?.let { + notifyError( + FirebaseFunctionsException( + it.toString(), + FirebaseFunctionsException.Code.INTERNAL, + it + ) + ) + } + } + json.has("result") -> { + serializer.decode(json.opt("result"))?.let { + notifyData(it) + notifyComplete() + } + return } - return } + } catch (e: Throwable) { + notifyError( + FirebaseFunctionsException( + "Invalid JSON: $dataChunk", + FirebaseFunctionsException.Code.INTERNAL, + e + ) + ) } - } catch (e: Throwable) { - throw FirebaseFunctionsException( - "${e.message} Invalid JSON: $dataChunk", + } + notifyError( + FirebaseFunctionsException( + "Stream ended unexpectedly without completion", + FirebaseFunctionsException.Code.INTERNAL, + null + ) + ) + } catch (e: Exception) { + notifyError( + FirebaseFunctionsException( + e.message ?: "Error reading stream", FirebaseFunctionsException.Code.INTERNAL, e ) - } + ) } + } + } + + private fun notifyData(data: Any?) { + for (subscriber in subscribers) { + subscriber.onNext(data!!) + } + } + + private fun notifyError(e: FirebaseFunctionsException) { + for (subscriber in subscribers) { + subscriber.onError(e) + } + subscribers.clear() + } + + private fun notifyComplete() { + for (subscriber in subscribers) { + subscriber.onComplete() + } + subscribers.clear() + } + + private fun validateResponse(response: Response) { + if (response.isSuccessful) return + + val htmlContentType = "text/html; charset=utf-8" + val trimMargin: String + if (response.code() == 404 && response.header("Content-Type") == htmlContentType) { + trimMargin = """URL not found. Raw response: ${response.body()?.string()}""".trimMargin() throw FirebaseFunctionsException( - "Stream ended unexpectedly without completion.", - FirebaseFunctionsException.Code.INTERNAL, + trimMargin, + FirebaseFunctionsException.Code.fromHttpStatus(response.code()), null ) - } catch (e: Exception) { + } + + val text = response.body()?.string() ?: "" + val error: Any? + try { + val json = JSONObject(text) + error = serializer.decode(json.opt("error")) + } catch (e: Throwable) { throw FirebaseFunctionsException( - e.message ?: "Error reading stream", + "${e.message} Unexpected Response:\n$text ", FirebaseFunctionsException.Code.INTERNAL, e ) } + throw FirebaseFunctionsException( + error.toString(), + FirebaseFunctionsException.Code.INTERNAL, + error + ) } } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt index a18849f619f..9b451d74c40 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/HttpsCallableReference.kt @@ -17,6 +17,7 @@ import androidx.annotation.VisibleForTesting import com.google.android.gms.tasks.Task import java.net.URL import java.util.concurrent.TimeUnit +import org.reactivestreams.Publisher /** A reference to a particular Callable HTTPS trigger in Cloud Functions. */ public class HttpsCallableReference { @@ -155,13 +156,13 @@ public class HttpsCallableReference { * Instance ID the next time you call this method. * * @param data Parameters to pass to the endpoint. - * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. + * @return [Publisher] that will be completed when the streaming operation has finished. * @see org.json.JSONArray * @see org.json.JSONObject * @see java.io.IOException * @see FirebaseFunctionsException */ - public fun stream(data: Any?): StreamFunctionsTask { + public fun stream(data: Any?): Publisher { return if (name != null) { functionsClient.stream(name, data, options) } else { @@ -181,9 +182,9 @@ public class HttpsCallableReference { * [com.google.firebase.iid.FirebaseInstanceId.deleteInstanceId]. It will resume with a new * Instance ID the next time you call this method. * - * @return [StreamFunctionsTask] that will be completed when the streaming operation has finished. + * @return [Publisher] that will be completed when the streaming operation has finished. */ - public fun stream(): StreamFunctionsTask { + public fun stream(): Publisher { return if (name != null) { functionsClient.stream(name, null, options) } else { From 1584847eeca9a2e6fb67c328758886bc2bae4c1b Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 22:50:37 -0800 Subject: [PATCH 7/9] Fix test case testGenStreamError. --- .../java/com/google/firebase/functions/StreamTests.kt | 7 ++++--- .../com/google/firebase/functions/FirebaseFunctions.kt | 7 ++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt index a1a5efbf569..0fa8c8c9ad7 100644 --- a/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt +++ b/firebase-functions/src/androidTest/java/com/google/firebase/functions/StreamTests.kt @@ -80,10 +80,11 @@ class StreamTests { @Test fun testGenStreamError() { val input = hashMapOf("data" to "Why is the sky blue") - val function = functions.getHttpsCallable("genStreamError").withTimeout(1, TimeUnit.SECONDS) + val function = + functions.getHttpsCallable("genStreamError").withTimeout(800, TimeUnit.MILLISECONDS) function.stream(input).subscribe(subscriber) - Thread.sleep(8000) + Thread.sleep(2000) val onNextStringList = onNextList.map { it.toString() } assertThat(onNextStringList) @@ -91,7 +92,6 @@ class StreamTests { "{chunk=hello}", ) assertThat(throwable).isNotNull() - assertThat(requireNotNull(throwable).message).isEqualTo("timeout") assertThat(isComplete).isFalse() } @@ -112,6 +112,7 @@ class StreamTests { "{chunk=is}", "{chunk=cool}" ) + assertThat(throwable).isNull() assertThat(isComplete).isFalse() } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt index 2bbc904372b..6962cb4fb07 100644 --- a/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt +++ b/firebase-functions/src/main/java/com/google/firebase/functions/FirebaseFunctions.kt @@ -336,7 +336,7 @@ internal constructor( return PublisherStream(url, data, options, client, this.serializer, task, executor) } - internal class PublisherStream( + private class PublisherStream( private val url: URL, private val data: Any?, private val options: HttpsCallOptions, @@ -387,12 +387,9 @@ internal constructor( MediaType.parse("application/json"), JSONObject(mapOf("data" to serializer.encode(data))).toString() ) - val requestBuilder = Request.Builder().url(url).post(requestBody).header("Accept", "text/event-stream") - applyCommonConfiguration(requestBuilder, context) - val request = requestBuilder.build() val call = callClient.newCall(request) activeCall = call @@ -517,7 +514,7 @@ internal constructor( private fun notifyData(data: Any?) { for (subscriber in subscribers) { - subscriber.onNext(data!!) + subscriber.onNext(data) } } From ee964a5228e6fb6960ac7eb807802a678e4b5139 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 22:55:43 -0800 Subject: [PATCH 8/9] Update api.txt. --- firebase-functions/api.txt | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index 2de8f1a358b..aaa8fe4fa5c 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -87,6 +87,7 @@ package com.google.firebase.functions { method public long getTimeout(); method public void setTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(@Nullable Object data = null); + method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(); method @NonNull public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); property public final long timeout; } @@ -96,32 +97,6 @@ package com.google.firebase.functions { field @Nullable public final Object data; } - public final class StreamFunctionsTask extends com.google.android.gms.tasks.Task { - ctor public StreamFunctionsTask(); - method @NonNull public com.google.firebase.functions.StreamFunctionsTask addOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); - method public void removeOnStreamListener(@NonNull com.google.firebase.functions.StreamListener listener); - method public void notifyListeners(@NonNull Object data); - method public void complete(@NonNull com.google.firebase.functions.HttpsCallableResult result); - method public void fail(@NonNull Exception exception); - method @Nullable public Exception getException(); - method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(); - method @NonNull public com.google.firebase.functions.HttpsCallableResult getResult(@NonNull Class p0); - method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull com.google.android.gms.tasks.OnFailureListener p0); - method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnFailureListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnFailureListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull java.util.concurrent.Executor p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull android.app.Activity p0, @NonNull com.google.android.gms.tasks.OnSuccessListener p1); - method @NonNull public com.google.android.gms.tasks.Task addOnSuccessListener(@NonNull com.google.android.gms.tasks.OnSuccessListener p0); - method public boolean isCanceled(); - method public boolean isComplete(); - method public boolean isSuccessful(); - method public void cancel(); - } - - public fun interface StreamListener { - method public void onNext(@NonNull Object message); - } - } package com.google.firebase.functions.ktx { From 895ee023d481e4a268a120e3dc4782b3897bb294 Mon Sep 17 00:00:00 2001 From: mustafajadid Date: Mon, 3 Feb 2025 23:01:33 -0800 Subject: [PATCH 9/9] Remove StreamListener.kt and StreamFunctionsTask.kt --- firebase-functions/api.txt | 4 +- .../firebase/functions/StreamFunctionsTask.kt | 121 ------------------ .../firebase/functions/StreamListener.kt | 8 -- 3 files changed, 2 insertions(+), 131 deletions(-) delete mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt delete mode 100644 firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt diff --git a/firebase-functions/api.txt b/firebase-functions/api.txt index aaa8fe4fa5c..d1a24bc1749 100644 --- a/firebase-functions/api.txt +++ b/firebase-functions/api.txt @@ -86,8 +86,8 @@ package com.google.firebase.functions { method @NonNull public com.google.android.gms.tasks.Task call(); method public long getTimeout(); method public void setTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); - method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(@Nullable Object data = null); - method @NonNull public com.google.firebase.functions.StreamFunctionsTask stream(); + method @NonNull public org.reactivestreams.Publisher stream(@Nullable Object data = null); + method @NonNull public org.reactivestreams.Publisher stream(); method @NonNull public com.google.firebase.functions.HttpsCallableReference withTimeout(long timeout, @NonNull java.util.concurrent.TimeUnit units); property public final long timeout; } diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt deleted file mode 100644 index 3c206022838..00000000000 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamFunctionsTask.kt +++ /dev/null @@ -1,121 +0,0 @@ -package com.google.firebase.functions - -import android.app.Activity -import com.google.android.gms.tasks.OnFailureListener -import com.google.android.gms.tasks.OnSuccessListener -import com.google.android.gms.tasks.Task -import java.util.Queue -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.Executor - -public class StreamFunctionsTask : Task() { - - private val listenerQueue: Queue = ConcurrentLinkedQueue() - private var result: HttpsCallableResult? = null - private var exception: Exception? = null - private var isComplete: Boolean = false - private var isCanceled = false - - public fun addOnStreamListener(listener: StreamListener): StreamFunctionsTask { - listenerQueue.add(listener) - return this - } - - public fun removeOnStreamListener(listener: StreamListener) { - listenerQueue.remove(listener) - } - - internal fun notifyListeners(data: Any) { - for (listener in listenerQueue) { - listener.onNext(data) - } - } - - internal fun complete(result: HttpsCallableResult) { - this.result = result - this.isComplete = true - } - - internal fun fail(exception: Exception) { - this.exception = exception - this.isComplete = true - } - - override fun getException(): Exception? { - listenerQueue.clear() - return exception - } - - override fun getResult(): HttpsCallableResult { - listenerQueue.clear() - return result ?: throw IllegalStateException("No result available.") - } - - override fun getResult(p0: Class): HttpsCallableResult { - if (p0.isInstance(exception)) { - throw p0.cast(exception)!! - } - return getResult() - } - - override fun addOnFailureListener(listener: OnFailureListener): Task { - if (exception != null) listener.onFailure(requireNotNull(exception)) - return this - } - - override fun addOnFailureListener( - activity: Activity, - listener: OnFailureListener - ): Task { - if (exception != null) listener.onFailure(requireNotNull(exception)) - return this - } - - override fun addOnFailureListener( - executor: Executor, - listener: OnFailureListener - ): Task { - if (exception != null) executor.execute { listener.onFailure(requireNotNull(exception)) } - return this - } - - override fun addOnSuccessListener( - executor: Executor, - listener: OnSuccessListener - ): Task { - if (result != null) executor.execute { listener.onSuccess(requireNotNull(result)) } - return this - } - - override fun addOnSuccessListener( - activity: Activity, - listener: OnSuccessListener - ): Task { - if (result != null) listener.onSuccess(requireNotNull(result)) - return this - } - - override fun addOnSuccessListener( - listener: OnSuccessListener - ): Task { - if (result != null) listener.onSuccess(requireNotNull(result)) - return this - } - - override fun isCanceled(): Boolean { - return isCanceled - } - - override fun isComplete(): Boolean { - return isComplete - } - - override fun isSuccessful(): Boolean { - return exception == null && result != null - } - - public fun cancel() { - isCanceled = true - listenerQueue.clear() - } -} diff --git a/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt b/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt deleted file mode 100644 index 539c55be3a4..00000000000 --- a/firebase-functions/src/main/java/com/google/firebase/functions/StreamListener.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.google.firebase.functions - -/** Listener for events from a Server-Sent Events stream. */ -public interface StreamListener { - - /** Called when a new event is received. */ - public fun onNext(message: Any) -}