Merge "Expose AppFunctionTestRule as an API to be used in Robolectric tests." into androidx-main
diff --git a/appfunctions/appfunctions-testing/api/current.txt b/appfunctions/appfunctions-testing/api/current.txt
index e6f50d0..1a2a9a2 100644
--- a/appfunctions/appfunctions-testing/api/current.txt
+++ b/appfunctions/appfunctions-testing/api/current.txt
@@ -1 +1,10 @@
 // Signature format: 4.0
+package androidx.appfunctions.testing {
+
+  @RequiresApi(android.os.Build.VERSION_CODES.TIRAMISU) public final class AppFunctionTestRule implements org.junit.rules.TestRule {
+    ctor public AppFunctionTestRule(android.content.Context context);
+    method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement? base, org.junit.runner.Description? description);
+  }
+
+}
+
diff --git a/appfunctions/appfunctions-testing/api/restricted_current.txt b/appfunctions/appfunctions-testing/api/restricted_current.txt
index e6f50d0..1a2a9a2 100644
--- a/appfunctions/appfunctions-testing/api/restricted_current.txt
+++ b/appfunctions/appfunctions-testing/api/restricted_current.txt
@@ -1 +1,10 @@
 // Signature format: 4.0
+package androidx.appfunctions.testing {
+
+  @RequiresApi(android.os.Build.VERSION_CODES.TIRAMISU) public final class AppFunctionTestRule implements org.junit.rules.TestRule {
+    ctor public AppFunctionTestRule(android.content.Context context);
+    method public org.junit.runners.model.Statement apply(org.junit.runners.model.Statement? base, org.junit.runner.Description? description);
+  }
+
+}
+
diff --git a/appfunctions/appfunctions-testing/build.gradle b/appfunctions/appfunctions-testing/build.gradle
index ab3decc..3100a8f 100644
--- a/appfunctions/appfunctions-testing/build.gradle
+++ b/appfunctions/appfunctions-testing/build.gradle
@@ -35,6 +35,7 @@
 
     implementation(project(":appfunctions:appfunctions"))
     implementation(project(":appfunctions:appfunctions-service"))
+    implementation("junit:junit:4.13.2")
 
     // Internal dependencies
     implementation("androidx.annotation:annotation:1.9.0-rc01")
diff --git a/appfunctions/appfunctions-testing/src/main/java/androidx/appfunctions/testing/AppFunctionTestEnvironment.kt b/appfunctions/appfunctions-testing/src/main/java/androidx/appfunctions/testing/AppFunctionTestEnvironment.kt
deleted file mode 100644
index a2d666c..0000000
--- a/appfunctions/appfunctions-testing/src/main/java/androidx/appfunctions/testing/AppFunctionTestEnvironment.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-/*
- * 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.testing
-
-import android.content.Context
-import android.os.Build
-import androidx.annotation.RequiresApi
-import androidx.appfunctions.AppFunctionManagerCompat
-import androidx.appfunctions.testing.internal.FakeAppFunctionManagerApi
-import androidx.appfunctions.testing.internal.FakeAppFunctionReader
-
-/**
- * Test environment for testing AppFunction API(s).
- *
- * Prefer using real setup if possible and only rely on this environment for unit or robolectric
- * tests to simulate cross app interactions via app functions.
- *
- * Functions defined via [androidx.appfunctions.service.AppFunction] annotation alongside test code
- * will already be registered to this environment on initialization, if `appfunctions-compiler` is
- * applied.
- */
-@RequiresApi(Build.VERSION_CODES.S)
-internal class AppFunctionTestEnvironment(private val context: Context) {
-    // TODO: b/418017070 - Dynamic registration and changing app function enabled state API(s).
-    // TODO: b/418017070 - Support function execution.
-
-    private val appFunctionReader = FakeAppFunctionReader(context)
-    private val appFunctionManagerApi = FakeAppFunctionManagerApi(context, appFunctionReader)
-
-    /**
-     * Returns an [AppFunctionManagerCompat] instance that can be used to interact with AppFunctions
-     * registered in this environment.
-     */
-    internal fun getAppFunctionManagerCompat(): AppFunctionManagerCompat =
-        AppFunctionManagerCompat(context, appFunctionReader, appFunctionManagerApi)
-}
diff --git a/appfunctions/appfunctions-testing/src/main/java/androidx/appfunctions/testing/AppFunctionTestRule.kt b/appfunctions/appfunctions-testing/src/main/java/androidx/appfunctions/testing/AppFunctionTestRule.kt
new file mode 100644
index 0000000..1b08ac7
--- /dev/null
+++ b/appfunctions/appfunctions-testing/src/main/java/androidx/appfunctions/testing/AppFunctionTestRule.kt
@@ -0,0 +1,58 @@
+/*
+ * 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.testing
+
+import android.content.Context
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.appfunctions.AppFunctionManagerCompat
+import androidx.appfunctions.testing.internal.FakeAppFunctionManagerApi
+import androidx.appfunctions.testing.internal.FakeAppFunctionReader
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+
+/**
+ * A JUnit TestRule for setting up an environment to exercise AppFunction APIs in unit or
+ * Robolectric tests.
+ *
+ * Prefer using the real system setup whenever possible. This rule should only be used for local
+ * tests that simulate cross-app interactions via AppFunctions.
+ *
+ * Any functions annotated with [androidx.appfunctions.service.AppFunction] in test code will be
+ * automatically registered in this environment during initialization, provided the
+ * `appfunctions-compiler` is applied to the test configuration.
+ */
+@RequiresApi(Build.VERSION_CODES.TIRAMISU)
+public class AppFunctionTestRule(private val context: Context) : TestRule {
+    // TODO: b/418017070 - Dynamic registration and changing app function enabled state API(s).
+    // TODO: b/418017070 - Support function execution.
+    // TODO: b/425327400 - Move to use Robolectric shadows
+
+    override fun apply(base: Statement?, description: Description?): Statement =
+        object : Statement() {
+            override fun evaluate() {
+                val appFunctionReader = FakeAppFunctionReader(context)
+                val appFunctionManagerApi = FakeAppFunctionManagerApi(context, appFunctionReader)
+                AppFunctionManagerCompat.setAppFunctionReader(appFunctionReader)
+                AppFunctionManagerCompat.setAppFunctionManagerApi(appFunctionManagerApi)
+                AppFunctionManagerCompat.setSkipExtensionLibraryCheck(true)
+
+                base?.evaluate()
+            }
+        }
+}
diff --git a/appfunctions/appfunctions-testing/src/test/java/androidx/appfunctions/testing/AppFunctionTestEnvironmentTest.kt b/appfunctions/appfunctions-testing/src/test/java/androidx/appfunctions/testing/AppFunctionTestRuleTest.kt
similarity index 90%
rename from appfunctions/appfunctions-testing/src/test/java/androidx/appfunctions/testing/AppFunctionTestEnvironmentTest.kt
rename to appfunctions/appfunctions-testing/src/test/java/androidx/appfunctions/testing/AppFunctionTestRuleTest.kt
index 3f9eb0d..4d4bace 100644
--- a/appfunctions/appfunctions-testing/src/test/java/androidx/appfunctions/testing/AppFunctionTestEnvironmentTest.kt
+++ b/appfunctions/appfunctions-testing/src/test/java/androidx/appfunctions/testing/AppFunctionTestRuleTest.kt
@@ -26,6 +26,7 @@
 import androidx.test.platform.app.InstrumentationRegistry
 import com.google.common.truth.Truth.assertThat
 import kotlin.test.assertIs
+import kotlin.test.assertNotNull
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.SharingStarted
@@ -34,6 +35,8 @@
 import kotlinx.coroutines.flow.take
 import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.runBlocking
+import org.junit.Before
+import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 import org.robolectric.RobolectricTestRunner
@@ -42,17 +45,22 @@
 @RunWith(RobolectricTestRunner::class)
 @Config(minSdk = Build.VERSION_CODES.TIRAMISU)
 @SdkSuppress(minSdkVersion = Build.VERSION_CODES.TIRAMISU)
-class AppFunctionTestEnvironmentTest {
+class AppFunctionTestRuleTest {
     private val context = InstrumentationRegistry.getInstrumentation().context
     private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
 
-    private val appFunctionTestEnvironment = AppFunctionTestEnvironment(targetContext)
+    @get:Rule val appFunctionTestRule = AppFunctionTestRule(targetContext)
+
+    private lateinit var appFunctionManagerCompat: AppFunctionManagerCompat
+
+    @Before
+    fun setUp() {
+        appFunctionManagerCompat = assertNotNull(AppFunctionManagerCompat.getInstance(context))
+    }
 
     @Test
     fun returnedAppFunctionManagerCompat_observeApi_returnsAllAppFunctions() =
         runBlocking<Unit> {
-            val appFunctionManagerCompat = appFunctionTestEnvironment.getAppFunctionManagerCompat()
-
             val results =
                 appFunctionManagerCompat
                     .observeAppFunctions(
@@ -68,7 +76,6 @@
     fun returnedAppFunctionManagerCompat_observeApi_returnsNewValueOnUpdate() =
         runBlocking<Unit> {
             val functionIdToTest = "androidx.appfunctions.testing.TestFunctions#disabledByDefault"
-            val appFunctionManagerCompat = appFunctionTestEnvironment.getAppFunctionManagerCompat()
             val appFunctionSearchFlow =
                 appFunctionManagerCompat.observeAppFunctions(
                     AppFunctionSearchSpec(packageNames = setOf(context.packageName))
@@ -102,7 +109,6 @@
     fun returnedAppFunctionManagerCompat_currentPackage_enabledByDefault_modified_success() =
         runBlocking<Unit> {
             val functionId = "androidx.appfunctions.testing.TestFunctions#enabledByDefault"
-            val appFunctionManagerCompat = appFunctionTestEnvironment.getAppFunctionManagerCompat()
             assertThat(appFunctionManagerCompat.isAppFunctionEnabled(functionId)).isTrue()
 
             appFunctionManagerCompat.setAppFunctionEnabled(
@@ -117,7 +123,6 @@
     fun returnedAppFunctionManagerCompat_currentPackage_disabledByDefault_modified_success() =
         runBlocking<Unit> {
             val functionId = "androidx.appfunctions.testing.TestFunctions#disabledByDefault"
-            val appFunctionManagerCompat = appFunctionTestEnvironment.getAppFunctionManagerCompat()
             assertThat(appFunctionManagerCompat.isAppFunctionEnabled(functionId)).isFalse()
 
             appFunctionManagerCompat.setAppFunctionEnabled(
@@ -131,7 +136,6 @@
     @Test
     fun executeAppFunction_success() =
         runBlocking<Unit> {
-            val appFunctionManagerCompat = appFunctionTestEnvironment.getAppFunctionManagerCompat()
             val response =
                 appFunctionManagerCompat.executeAppFunction(
                     request =
@@ -158,7 +162,6 @@
     fun returnedAppFunctionManagerCompat_currentPackage_disabledByDefault_modifiedAndRestoredToDefault_success() =
         runBlocking<Unit> {
             val functionId = "androidx.appfunctions.testing.TestFunctions#disabledByDefault"
-            val appFunctionManagerCompat = appFunctionTestEnvironment.getAppFunctionManagerCompat()
             assertThat(appFunctionManagerCompat.isAppFunctionEnabled(functionId)).isFalse()
 
             appFunctionManagerCompat.setAppFunctionEnabled(
diff --git a/appfunctions/appfunctions/src/main/java/androidx/appfunctions/AppFunctionManagerCompat.kt b/appfunctions/appfunctions/src/main/java/androidx/appfunctions/AppFunctionManagerCompat.kt
index ce07423..70cabe0 100644
--- a/appfunctions/appfunctions/src/main/java/androidx/appfunctions/AppFunctionManagerCompat.kt
+++ b/appfunctions/appfunctions/src/main/java/androidx/appfunctions/AppFunctionManagerCompat.kt
@@ -22,6 +22,8 @@
 import androidx.annotation.IntDef
 import androidx.annotation.RequiresPermission
 import androidx.annotation.RestrictTo
+import androidx.appfunctions.AppFunctionManagerCompat.Companion.getInstance
+import androidx.appfunctions.AppFunctionManagerCompat.Companion.isExtensionLibraryAvailable
 import androidx.appfunctions.internal.AppFunctionManagerApi
 import androidx.appfunctions.internal.AppFunctionReader
 import androidx.appfunctions.internal.AppSearchAppFunctionReader
@@ -212,6 +214,43 @@
         /** The version shared across all schema defined in the legacy SDK. */
         private const val LEGACY_SDK_GLOBAL_SCHEMA_VERSION = 1L
 
+        private var _appFunctionReader: AppFunctionReader? = null
+        private var _appFunctionManagerApi: AppFunctionManagerApi? = null
+
+        private var _skipExtensionLibraryCheck = false
+
+        /**
+         * Allows overriding the [AppFunctionReader] used for constructing
+         * [AppFunctionManagerCompat] instance in [getInstance] with a different implementation.
+         *
+         * Only meant to be used internally by `AppFunctionTestRule`.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        public fun setAppFunctionReader(appFunctionReader: AppFunctionReader) {
+            _appFunctionReader = appFunctionReader
+        }
+
+        /**
+         * Allows overriding the [AppFunctionManagerApi] used for constructing
+         * [AppFunctionManagerCompat] instance in [getInstance] with a different implementation.
+         *
+         * Only meant to be used internally by `AppFunctionTestRule`.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        public fun setAppFunctionManagerApi(appFunctionManagerApi: AppFunctionManagerApi) {
+            _appFunctionManagerApi = appFunctionManagerApi
+        }
+
+        /**
+         * Allows skipping [isExtensionLibraryAvailable] check in [getInstance].
+         *
+         * Only meant to be used internally by `AppFunctionTestRule`.
+         */
+        @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+        public fun setSkipExtensionLibraryCheck(skipExtensionLibraryCheck: Boolean) {
+            _skipExtensionLibraryCheck = skipExtensionLibraryCheck
+        }
+
         /**
          * Checks whether the AppFunction extension library is available.
          *
@@ -219,6 +258,8 @@
          *   otherwise.
          */
         private fun isExtensionLibraryAvailable(): Boolean {
+            if (_skipExtensionLibraryCheck) return true
+
             return try {
                 Class.forName("com.android.extensions.appfunctions.AppFunctionManager")
                 true
@@ -244,11 +285,12 @@
                 Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA -> {
                     AppFunctionManagerCompat(
                         context,
-                        AppSearchAppFunctionReader(
-                            context,
-                            Dependencies.schemaAppFunctionInventory,
-                        ),
-                        PlatformAppFunctionManagerApi(context),
+                        _appFunctionReader
+                            ?: AppSearchAppFunctionReader(
+                                context,
+                                Dependencies.schemaAppFunctionInventory,
+                            ),
+                        _appFunctionManagerApi ?: PlatformAppFunctionManagerApi(context),
                         Dependencies.translatorSelector,
                     )
                 }
@@ -256,11 +298,12 @@
                     isExtensionLibraryAvailable() -> {
                     AppFunctionManagerCompat(
                         context,
-                        AppSearchAppFunctionReader(
-                            context,
-                            Dependencies.schemaAppFunctionInventory,
-                        ),
-                        ExtensionAppFunctionManagerApi(context),
+                        _appFunctionReader
+                            ?: AppSearchAppFunctionReader(
+                                context,
+                                Dependencies.schemaAppFunctionInventory,
+                            ),
+                        _appFunctionManagerApi ?: ExtensionAppFunctionManagerApi(context),
                         Dependencies.translatorSelector,
                     )
                 }