Merge "Add AggregateAppFunctionInvoker" into androidx-main
diff --git a/appfunctions/appfunctions-runtime/build.gradle b/appfunctions/appfunctions-runtime/build.gradle
index a95d75a..146190f 100644
--- a/appfunctions/appfunctions-runtime/build.gradle
+++ b/appfunctions/appfunctions-runtime/build.gradle
@@ -36,8 +36,15 @@
     implementation("androidx.annotation:annotation:1.9.0-rc01")
     implementation project(":appfunctions:appfunctions-common")
 
+    // Test dependencies
     testImplementation(libs.junit)
     testImplementation(libs.truth)
+
+    androidTestImplementation(libs.testCore)
+    androidTestImplementation(libs.testRules)
+    androidTestImplementation(libs.junit)
+    androidTestImplementation(libs.truth)
+    androidTestImplementation(libs.kotlinCoroutinesTest)
 }
 
 android {
diff --git a/appfunctions/appfunctions-runtime/src/androidTest/java/androidx/appfunctions/internal/AggregateAppFunctionInvokerTest.kt b/appfunctions/appfunctions-runtime/src/androidTest/java/androidx/appfunctions/internal/AggregateAppFunctionInvokerTest.kt
new file mode 100644
index 0000000..1d63cd0
--- /dev/null
+++ b/appfunctions/appfunctions-runtime/src/androidTest/java/androidx/appfunctions/internal/AggregateAppFunctionInvokerTest.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appfunctions.internal
+
+import android.content.Context
+import android.content.pm.SigningInfo
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionFunctionNotFoundException
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert
+import org.junit.Test
+
+class AggregateAppFunctionInvokerTest {
+
+    @Test
+    fun testEmptyAggregateInvoker() {
+        val aggregateInvoker =
+            object : AggregateAppFunctionInvoker() {
+                override val invokers: List<AppFunctionInvoker> = emptyList()
+            }
+
+        assertThat(aggregateInvoker.supportedFunctionIds).isEmpty()
+        Assert.assertThrows(AppFunctionFunctionNotFoundException::class.java) {
+            runBlocking {
+                aggregateInvoker.unsafeInvoke(
+                    FakeAppFunctionContext,
+                    "androidx.apfunctions.internal#test1",
+                    mapOf()
+                )
+            }
+        }
+    }
+
+    @Test
+    fun testAggregateInvoker_nonExistFunction() {
+        val aggregateInvoker =
+            object : AggregateAppFunctionInvoker() {
+                override val invokers: List<AppFunctionInvoker> =
+                    listOf(
+                        Invoker1(),
+                        Invoker2(),
+                    )
+            }
+
+        assertThat(aggregateInvoker.supportedFunctionIds).hasSize(2)
+        assertThat(aggregateInvoker.supportedFunctionIds)
+            .containsNoneIn(listOf("androidx.appfunctions.internal#test0"))
+        Assert.assertThrows(AppFunctionFunctionNotFoundException::class.java) {
+            runBlocking {
+                aggregateInvoker.unsafeInvoke(
+                    FakeAppFunctionContext,
+                    "androidx.apfunctions.internal#test0",
+                    mapOf()
+                )
+            }
+        }
+    }
+
+    @Test
+    fun testAggregateInvoker_existFunctions() {
+        val aggregateInvoker =
+            object : AggregateAppFunctionInvoker() {
+                override val invokers: List<AppFunctionInvoker> =
+                    listOf(
+                        Invoker1(),
+                        Invoker2(),
+                    )
+            }
+
+        val invokeTest1Result = runBlocking {
+            aggregateInvoker.unsafeInvoke(
+                FakeAppFunctionContext,
+                "androidx.appfunctions.internal#test1",
+                mapOf()
+            )
+        }
+        val invokeTest2Result = runBlocking {
+            aggregateInvoker.unsafeInvoke(
+                FakeAppFunctionContext,
+                "androidx.appfunctions.internal#test2",
+                mapOf()
+            )
+        }
+
+        assertThat(aggregateInvoker.supportedFunctionIds)
+            .containsExactly(
+                "androidx.appfunctions.internal#test1",
+                "androidx.appfunctions.internal#test2"
+            )
+        assertThat(invokeTest1Result).isEqualTo("Invoker1#test1")
+        assertThat(invokeTest2Result).isEqualTo("Invoker2#test2")
+    }
+
+    private class Invoker1 : AppFunctionInvoker {
+        override val supportedFunctionIds: Set<String> =
+            setOf("androidx.appfunctions.internal#test1")
+
+        override suspend fun unsafeInvoke(
+            appFunctionContext: AppFunctionContext,
+            functionIdentifier: String,
+            parameters: Map<String, Any?>
+        ): Any? {
+            return when (functionIdentifier) {
+                "androidx.appfunctions.internal#test1" -> "Invoker1#test1"
+                else -> throw IllegalArgumentException()
+            }
+        }
+    }
+
+    private class Invoker2 : AppFunctionInvoker {
+        override val supportedFunctionIds: Set<String> =
+            setOf("androidx.appfunctions.internal#test2")
+
+        override suspend fun unsafeInvoke(
+            appFunctionContext: AppFunctionContext,
+            functionIdentifier: String,
+            parameters: Map<String, Any?>
+        ): Any? {
+            return when (functionIdentifier) {
+                "androidx.appfunctions.internal#test2" -> "Invoker2#test2"
+                else -> throw IllegalArgumentException()
+            }
+        }
+    }
+
+    private object FakeAppFunctionContext : AppFunctionContext {
+        override val context: Context
+            get() = throw RuntimeException("Stub!")
+
+        override val callingPackageName: String
+            get() = throw RuntimeException("Stub!")
+
+        override val callingPackageSigningInfo: SigningInfo
+            get() = throw RuntimeException("Stub!")
+    }
+}
diff --git a/appfunctions/appfunctions-runtime/src/main/java/androidx/appfunctions/internal/AggregateAppFunctionInvoker.kt b/appfunctions/appfunctions-runtime/src/main/java/androidx/appfunctions/internal/AggregateAppFunctionInvoker.kt
new file mode 100644
index 0000000..df6be5a
--- /dev/null
+++ b/appfunctions/appfunctions-runtime/src/main/java/androidx/appfunctions/internal/AggregateAppFunctionInvoker.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2025 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.appfunctions.internal
+
+import androidx.annotation.RestrictTo
+import androidx.appfunctions.AppFunctionContext
+import androidx.appfunctions.AppFunctionFunctionNotFoundException
+
+/**
+ * An [AppFunctionInvoker] that will delegate [unsafeInvoke] to the implementation that supports the
+ * given function call request.
+ *
+ * AppFunction compiler will automatically generate the implementation of this class.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public abstract class AggregateAppFunctionInvoker : AppFunctionInvoker {
+
+    /** The list of [AppFunctionInvoker] instances that contribute to this aggregate. */
+    public abstract val invokers: List<AppFunctionInvoker>
+
+    final override val supportedFunctionIds: Set<String> by lazy {
+        // Empty collection can't be reduced
+        if (invokers.isEmpty()) return@lazy emptySet<String>()
+        invokers.map(AppFunctionInvoker::supportedFunctionIds).reduce { acc, ids -> acc + ids }
+    }
+
+    final override suspend fun unsafeInvoke(
+        appFunctionContext: AppFunctionContext,
+        functionIdentifier: String,
+        parameters: Map<String, Any?>
+    ): Any? {
+        for (invoker in invokers) {
+            if (invoker.supportedFunctionIds.contains(functionIdentifier)) {
+                return invoker.unsafeInvoke(appFunctionContext, functionIdentifier, parameters)
+            }
+        }
+        throw AppFunctionFunctionNotFoundException("Unable to find $functionIdentifier")
+    }
+}
diff --git a/appfunctions/appfunctions-runtime/src/main/java/androidx/appfunctions/internal/AppFunctionInvoker.kt b/appfunctions/appfunctions-runtime/src/main/java/androidx/appfunctions/internal/AppFunctionInvoker.kt
index 59c4bca..4567641 100644
--- a/appfunctions/appfunctions-runtime/src/main/java/androidx/appfunctions/internal/AppFunctionInvoker.kt
+++ b/appfunctions/appfunctions-runtime/src/main/java/androidx/appfunctions/internal/AppFunctionInvoker.kt
@@ -50,11 +50,10 @@
     /**
      * Invokes an AppFunction identified by [functionIdentifier], with [parameters].
      *
-     * @throws [androidx.appfunctions.AppFunctionException] with error code
-     *   [androidx.appfunctions.AppFunctionException.ERROR_FUNCTION_NOT_FOUND] if called with
-     *   invalid function identifier or code
-     *   [androidx.appfunctions.AppFunctionException.ERROR_INVALID_ARGUMENT] if called with invalid
-     *   parameters.
+     * @throws [androidx.appfunctions.AppFunctionFunctionNotFoundException] if [functionIdentifier]
+     *   does not exist.
+     * @throws [androidx.appfunctions.AppFunctionInvalidArgumentException] if [parameters] is
+     *   invalid.
      */
     public suspend fun unsafeInvoke(
         appFunctionContext: AppFunctionContext,