1 /*
<lambda>null2  * Copyright 2025 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.appfunctions.internal
18 
19 import android.content.Context
20 import android.os.Build
21 import android.util.Log
22 import androidx.annotation.RequiresApi
23 import androidx.appfunctions.AppFunctionFunctionNotFoundException
24 import androidx.appfunctions.AppFunctionSearchSpec
25 import androidx.appfunctions.metadata.AppFunctionComponentsMetadata
26 import androidx.appfunctions.metadata.AppFunctionMetadata
27 import androidx.appfunctions.metadata.AppFunctionMetadataDocument
28 import androidx.appfunctions.metadata.AppFunctionPrimitiveTypeMetadata
29 import androidx.appfunctions.metadata.AppFunctionResponseMetadata
30 import androidx.appfunctions.metadata.AppFunctionRuntimeMetadata
31 import androidx.appfunctions.metadata.AppFunctionSchemaMetadata
32 import androidx.appsearch.app.GenericDocument
33 import androidx.appsearch.app.GetByDocumentIdRequest
34 import androidx.appsearch.app.GlobalSearchSession
35 import androidx.appsearch.app.JoinSpec
36 import androidx.appsearch.app.SearchResult
37 import androidx.appsearch.app.SearchSpec
38 import androidx.appsearch.observer.DocumentChangeInfo
39 import androidx.appsearch.observer.ObserverCallback
40 import androidx.appsearch.observer.ObserverSpec
41 import androidx.appsearch.observer.SchemaChangeInfo
42 import com.android.extensions.appfunctions.AppFunctionManager
43 import kotlin.time.Duration.Companion.seconds
44 import kotlinx.coroutines.FlowPreview
45 import kotlinx.coroutines.asExecutor
46 import kotlinx.coroutines.channels.Channel
47 import kotlinx.coroutines.channels.awaitClose
48 import kotlinx.coroutines.flow.Flow
49 import kotlinx.coroutines.flow.callbackFlow
50 import kotlinx.coroutines.flow.debounce
51 import kotlinx.coroutines.flow.flow
52 import kotlinx.coroutines.flow.receiveAsFlow
53 import kotlinx.coroutines.guava.await
54 import kotlinx.coroutines.launch
55 
56 /**
57  * A class responsible for reading and searching for app functions based on a search specification.
58  *
59  * It searches for AppFunction documents using the [GlobalSearchSession] and converts them into
60  * [AppFunctionMetadata] objects.
61  *
62  * @param context The context of the application, used for session creation.
63  */
64 @RequiresApi(Build.VERSION_CODES.S)
65 internal class AppSearchAppFunctionReader(private val context: Context) : AppFunctionReader {
66 
67     @OptIn(FlowPreview::class)
68     override fun searchAppFunctions(
69         searchFunctionSpec: AppFunctionSearchSpec
70     ): Flow<List<AppFunctionMetadata>> {
71         if (searchFunctionSpec.packageNames?.isEmpty() == true) {
72             return flow { emit(emptyList()) }
73         }
74 
75         return callbackFlow {
76             val session = createSearchSession(context)
77 
78             // Perform initial search immediately
79             send(performSearch(session, searchFunctionSpec))
80 
81             val appSearchChannelObserver = AppSearchChannelObserver()
82             // Register the observer callback
83             session.registerObserverCallback(
84                 SYSTEM_PACKAGE_NAME,
85                 buildObserverSpec(searchFunctionSpec.packageNames ?: emptySet()),
86                 Dispatchers.Worker.asExecutor(),
87                 appSearchChannelObserver
88             )
89 
90             // Coroutine to react to updates from the observer
91             val observerJob = launch {
92                 appSearchChannelObserver.observe().debounce(OBSERVER_DEBOUNCE_MILLIS).collect {
93                     // TODO(b/403264749): Check if we can skip the running a full search again by
94                     // caching the results.
95                     send(performSearch(session, searchFunctionSpec))
96                 }
97             }
98 
99             // Clean up when collection stops
100             awaitClose {
101                 observerJob.cancel()
102                 appSearchChannelObserver.close()
103                 session.unregisterObserverCallback(SYSTEM_PACKAGE_NAME, appSearchChannelObserver)
104                 session.close()
105             }
106         }
107     }
108 
109     private class AppSearchChannelObserver : ObserverCallback {
110         private val updateChannel = Channel<Unit>(Channel.RENDEZVOUS)
111 
112         override fun onSchemaChanged(changeInfo: SchemaChangeInfo) {
113             updateChannel.trySend(Unit)
114         }
115 
116         override fun onDocumentChanged(changeInfo: DocumentChangeInfo) {
117             updateChannel.trySend(Unit)
118         }
119 
120         fun observe(): Flow<Unit> = updateChannel.receiveAsFlow()
121 
122         fun close() {
123             updateChannel.close()
124         }
125     }
126 
127     private fun buildObserverSpec(packageNames: Set<String>) =
128         ObserverSpec.Builder()
129             .addFilterSchemas(
130                 packageNames.flatMap {
131                     listOf("AppFunctionStaticMetadata-$it", "AppFunctionRuntimeMetadata-$it")
132                 }
133             )
134             .build()
135 
136     private suspend fun performSearch(
137         session: GlobalSearchSession,
138         searchFunctionSpec: AppFunctionSearchSpec,
139     ): List<AppFunctionMetadata> {
140         val joinSpec =
141             JoinSpec.Builder(AppFunctionRuntimeMetadata.STATIC_METADATA_JOIN_PROPERTY)
142                 .setNestedSearch("", RUNTIME_SEARCH_SPEC)
143                 .build()
144 
145         val staticSearchSpec =
146             SearchSpec.Builder()
147                 .addFilterNamespaces(APP_FUNCTIONS_NAMESPACE)
148                 .addFilterDocumentClasses(AppFunctionMetadataDocument::class.java)
149                 .addFilterPackageNames(SYSTEM_PACKAGE_NAME)
150                 .setJoinSpec(joinSpec)
151                 .setVerbatimSearchEnabled(true)
152                 .setNumericSearchEnabled(true)
153                 .setListFilterQueryLanguageEnabled(true)
154                 .build()
155         return session
156             .search(searchFunctionSpec.toStaticMetadataAppSearchQuery(), staticSearchSpec)
157             .readAll(::convertSearchResultToAppFunctionMetadata)
158             .filterNotNull()
159     }
160 
161     private fun convertSearchResultToAppFunctionMetadata(
162         searchResult: SearchResult
163     ): AppFunctionMetadata? {
164 
165         // This is different from document id which for uniqueness is computed as packageName + "/"
166         // + functionId.
167         val functionId = checkNotNull(searchResult.genericDocument.getPropertyString("functionId"))
168         val packageName =
169             checkNotNull(searchResult.genericDocument.getPropertyString("packageName"))
170 
171         // TODO: Handle failures and log instead of throwing.
172         val staticMetadataDocument =
173             searchResult.genericDocument.toDocumentClass(AppFunctionMetadataDocument::class.java)
174         val runtimeMetadataDocument =
175             searchResult.joinedResults
176                 .single()
177                 .genericDocument
178                 .toDocumentClass(AppFunctionRuntimeMetadata::class.java)
179 
180         return AppFunctionMetadata(
181             id = functionId,
182             packageName = packageName,
183             isEnabled = computeEffectivelyEnabled(staticMetadataDocument, runtimeMetadataDocument),
184             schema = buildSchemaMetadataFromGdForLegacyIndexer(searchResult.genericDocument),
185             // TODO: Populate them separately for legacy indexer.
186             parameters =
187                 // Since this is a list type it can be null for cases where an app function has no
188                 // parameters.
189                 if (staticMetadataDocument.response != null) {
190                     staticMetadataDocument.parameters?.map { it.toAppFunctionParameterMetadata() }
191                         ?: emptyList()
192                 } else {
193                     // TODO - Populate for legacy indexer
194                     emptyList()
195                 },
196             response =
197                 staticMetadataDocument.response?.toAppFunctionResponseMetadata()
198                     ?: AppFunctionResponseMetadata(
199                         valueType =
200                             AppFunctionPrimitiveTypeMetadata(
201                                 type = AppFunctionPrimitiveTypeMetadata.TYPE_UNIT,
202                                 isNullable = false
203                             )
204                     ),
205             components =
206                 staticMetadataDocument.components?.toAppFunctionComponentsMetadata()
207                     ?: AppFunctionComponentsMetadata(),
208         )
209     }
210 
211     private fun computeEffectivelyEnabled(
212         staticMetadata: AppFunctionMetadataDocument,
213         runtimeMetadata: AppFunctionRuntimeMetadata,
214     ): Boolean =
215         when (runtimeMetadata.enabled.toInt()) {
216             AppFunctionManager.APP_FUNCTION_STATE_ENABLED -> true
217             AppFunctionManager.APP_FUNCTION_STATE_DISABLED -> false
218             AppFunctionManager.APP_FUNCTION_STATE_DEFAULT -> staticMetadata.isEnabledByDefault
219             else ->
220                 throw IllegalStateException(
221                     "Unknown AppFunction state: ${runtimeMetadata.enabled}."
222                 )
223         }
224 
225     private fun buildSchemaMetadataFromGdForLegacyIndexer(
226         document: GenericDocument
227     ): AppFunctionSchemaMetadata? {
228         val schemaName = document.getPropertyString("schemaName")
229         val schemaCategory = document.getPropertyString("schemaCategory")
230         val schemaVersion = document.getPropertyLong("schemaVersion")
231 
232         if (schemaName == null || schemaCategory == null || schemaVersion == 0L) {
233             if (schemaName != null || schemaCategory != null || schemaVersion != 0L) {
234                 Log.e(
235                     AppFunctionReader::class.simpleName,
236                     "Unexpected state: schemaName=$schemaName, schemaCategory=$schemaCategory, schemaVersion=$schemaVersion"
237                 )
238             }
239             return null
240         }
241 
242         return AppFunctionSchemaMetadata(
243             name = schemaName,
244             category = schemaCategory,
245             version = schemaVersion
246         )
247     }
248 
249     /**
250      * Returns the [AppFunctionSchemaMetadata] of the given app function. Returns null if the
251      * function is not implementing a predefined schema.
252      *
253      * @throws AppFunctionFunctionNotFoundException if the function does not exist.
254      */
255     override suspend fun getAppFunctionSchemaMetadata(
256         functionId: String,
257         packageName: String
258     ): AppFunctionSchemaMetadata? {
259         val documentId = getAppFunctionId(packageName, functionId)
260         val result =
261             createSearchSession(context = context)
262                 .use { session ->
263                     session.getByDocumentIdAsync(
264                         SYSTEM_PACKAGE_NAME,
265                         APP_FUNCTIONS_STATIC_DATABASE_NAME,
266                         GetByDocumentIdRequest.Builder(APP_FUNCTIONS_NAMESPACE)
267                             .addIds(documentId)
268                             .build()
269                     )
270                 }
271                 .await()
272         val genericDocument =
273             result.successes[documentId]
274                 ?: throw AppFunctionFunctionNotFoundException(
275                     "Function with ID = $documentId is not available"
276                 )
277         return buildSchemaMetadataFromGdForLegacyIndexer(genericDocument)
278     }
279 
280     private fun getAppFunctionId(packageName: String, functionId: String) =
281         "$packageName/$functionId"
282 
283     private companion object {
284         const val SYSTEM_PACKAGE_NAME = "android"
285         const val APP_FUNCTIONS_NAMESPACE = "app_functions"
286         const val APP_FUNCTIONS_RUNTIME_NAMESPACE = "app_functions_runtime"
287         const val APP_FUNCTIONS_STATIC_DATABASE_NAME = "apps-db"
288 
289         val OBSERVER_DEBOUNCE_MILLIS = 1.seconds
290 
291         val RUNTIME_SEARCH_SPEC =
292             SearchSpec.Builder()
293                 .addFilterNamespaces(APP_FUNCTIONS_RUNTIME_NAMESPACE)
294                 .addFilterDocumentClasses(AppFunctionRuntimeMetadata::class.java)
295                 .addFilterPackageNames(SYSTEM_PACKAGE_NAME)
296                 .setVerbatimSearchEnabled(true)
297                 .build()
298     }
299 }
300