Merge "Use MetadataSyncAdapter in AppFunctionManagerService." into main
diff --git a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionExecutors.java b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionExecutors.java
index c3b7087..1f98334 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionExecutors.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionExecutors.java
@@ -16,7 +16,15 @@
 
 package com.android.server.appfunctions;
 
+import android.annotation.NonNull;
+import android.os.UserHandle;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.GuardedBy;
+
 import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.concurrent.LinkedBlockingQueue;
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.TimeUnit;
@@ -33,5 +41,50 @@
                     /* unit= */ TimeUnit.SECONDS,
                     /* workQueue= */ new LinkedBlockingQueue<>());
 
+    /** A map of per-user executors for queued work. */
+    @GuardedBy("sLock")
+    private static final SparseArray<ExecutorService> mPerUserExecutorsLocked = new SparseArray<>();
+
+    private static final Object sLock = new Object();
+
+    /**
+     * Returns a per-user executor for queued metadata sync request.
+     *
+     * <p>The work submitted to these executor (Sync request) needs to be synchronous per user hence
+     * the use of a single thread.
+     *
+     * <p>Note: Use a different executor if not calling {@code submitSyncRequest} on a {@code
+     * MetadataSyncAdapter}.
+     */
+    // TODO(b/357551503): Restrict the scope of this executor to the MetadataSyncAdapter itself.
+    public static ExecutorService getPerUserSyncExecutor(@NonNull UserHandle user) {
+        synchronized (sLock) {
+            ExecutorService executor = mPerUserExecutorsLocked.get(user.getIdentifier(), null);
+            if (executor == null) {
+                executor = Executors.newSingleThreadExecutor();
+                mPerUserExecutorsLocked.put(user.getIdentifier(), executor);
+            }
+            return executor;
+        }
+    }
+
+    /**
+     * Shuts down and removes the per-user executor for queued work.
+     *
+     * <p>This should be called when the user is removed.
+     */
+    public static void shutDownAndRemoveUserExecutor(@NonNull UserHandle user)
+            throws InterruptedException {
+        ExecutorService executor;
+        synchronized (sLock) {
+            executor = mPerUserExecutorsLocked.get(user.getIdentifier());
+            mPerUserExecutorsLocked.remove(user.getIdentifier());
+        }
+        if (executor != null) {
+            executor.shutdown();
+            var unused = executor.awaitTermination(30, TimeUnit.SECONDS);
+        }
+    }
+
     private AppFunctionExecutors() {}
 }
diff --git a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java
index 02800cb..c293087 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerService.java
@@ -16,6 +16,7 @@
 
 package com.android.server.appfunctions;
 
+import android.annotation.NonNull;
 import android.app.appfunctions.AppFunctionManagerConfiguration;
 import android.content.Context;
 
@@ -36,4 +37,14 @@
             publishBinderService(Context.APP_FUNCTION_SERVICE, mServiceImpl);
         }
     }
+
+    @Override
+    public void onUserUnlocked(@NonNull TargetUser user) {
+        mServiceImpl.onUserUnlocked(user);
+    }
+
+    @Override
+    public void onUserStopping(@NonNull TargetUser user) {
+        mServiceImpl.onUserStopping(user);
+    }
 }
diff --git a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
index 2362b91..cf039df 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/AppFunctionManagerServiceImpl.java
@@ -19,29 +19,35 @@
 import static com.android.server.appfunctions.AppFunctionExecutors.THREAD_POOL_EXECUTOR;
 
 import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.appfunctions.AppFunctionStaticMetadataHelper;
 import android.app.appfunctions.ExecuteAppFunctionAidlRequest;
 import android.app.appfunctions.ExecuteAppFunctionResponse;
 import android.app.appfunctions.IAppFunctionManager;
 import android.app.appfunctions.IAppFunctionService;
 import android.app.appfunctions.IExecuteAppFunctionCallback;
 import android.app.appfunctions.SafeOneTimeExecuteAppFunctionCallback;
+import android.app.appsearch.AppSearchManager;
+import android.app.appsearch.AppSearchResult;
+import android.app.appsearch.observer.DocumentChangeInfo;
+import android.app.appsearch.observer.ObserverCallback;
+import android.app.appsearch.observer.ObserverSpec;
+import android.app.appsearch.observer.SchemaChangeInfo;
 import android.content.Context;
 import android.content.Intent;
 import android.os.Binder;
 import android.os.UserHandle;
 import android.text.TextUtils;
 import android.util.Slog;
-import android.app.appsearch.AppSearchResult;
 
 import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.SystemService.TargetUser;
 import com.android.server.appfunctions.RemoteServiceCaller.RunServiceCallCallback;
 import com.android.server.appfunctions.RemoteServiceCaller.ServiceUsageCompleteListener;
 
+import java.io.IOException;
 import java.util.Objects;
 import java.util.concurrent.CompletionException;
-import java.util.concurrent.LinkedBlockingQueue;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
 
 /** Implementation of the AppFunctionManagerService. */
 public class AppFunctionManagerServiceImpl extends IAppFunctionManager.Stub {
@@ -51,9 +57,11 @@
     private final CallerValidator mCallerValidator;
     private final ServiceHelper mInternalServiceHelper;
     private final ServiceConfig mServiceConfig;
+    private final Context mContext;
 
     public AppFunctionManagerServiceImpl(@NonNull Context context) {
         this(
+                context,
                 new RemoteServiceCallerImpl<>(
                         context, IAppFunctionService.Stub::asInterface, THREAD_POOL_EXECUTOR),
                 new CallerValidatorImpl(context),
@@ -63,10 +71,12 @@
 
     @VisibleForTesting
     AppFunctionManagerServiceImpl(
+            Context context,
             RemoteServiceCaller<IAppFunctionService> remoteServiceCaller,
             CallerValidator callerValidator,
             ServiceHelper appFunctionInternalServiceHelper,
             ServiceConfig serviceConfig) {
+        mContext = Objects.requireNonNull(context);
         mRemoteServiceCaller = Objects.requireNonNull(remoteServiceCaller);
         mCallerValidator = Objects.requireNonNull(callerValidator);
         mInternalServiceHelper = Objects.requireNonNull(appFunctionInternalServiceHelper);
@@ -90,6 +100,26 @@
         }
     }
 
+    /** Called when the user is unlocked. */
+    public void onUserUnlocked(TargetUser user) {
+        Objects.requireNonNull(user);
+
+        registerAppSearchObserver(user);
+        trySyncRuntimeMetadata(user);
+    }
+
+    /** Called when the user is stopping. */
+    public void onUserStopping(@NonNull TargetUser user) {
+        Objects.requireNonNull(user);
+
+        try {
+            AppFunctionExecutors.shutDownAndRemoveUserExecutor(user.getUserHandle());
+            MetadataSyncPerUser.removeUserSyncAdapter(user.getUserHandle());
+        } catch (InterruptedException e) {
+            Slog.e(TAG, "Unable to remove data for: " + user.getUserHandle(), e);
+        }
+    }
+
     private void executeAppFunctionInternal(
             ExecuteAppFunctionAidlRequest requestInternal,
             SafeOneTimeExecuteAppFunctionCallback safeExecuteAppFunctionCallback) {
@@ -132,53 +162,55 @@
             return;
         }
 
-        var unused = mCallerValidator
-                .verifyCallerCanExecuteAppFunction(
-                        validatedCallingPackage,
-                        targetPackageName,
-                        requestInternal.getClientRequest().getFunctionIdentifier())
-                .thenAccept(
-                        canExecute -> {
-                            if (!canExecute) {
-                                safeExecuteAppFunctionCallback.onResult(
-                                        ExecuteAppFunctionResponse.newFailure(
-                                                ExecuteAppFunctionResponse.RESULT_DENIED,
-                                                "Caller does not have permission to execute the"
-                                                        + " appfunction",
-                                                /* extras= */ null));
-                                return;
-                            }
-                            Intent serviceIntent =
-                                    mInternalServiceHelper.resolveAppFunctionService(
-                                            targetPackageName, targetUser);
-                            if (serviceIntent == null) {
-                                safeExecuteAppFunctionCallback.onResult(
-                                        ExecuteAppFunctionResponse.newFailure(
-                                                ExecuteAppFunctionResponse.RESULT_INTERNAL_ERROR,
-                                                "Cannot find the target service.",
-                                                /* extras= */ null));
-                                return;
-                            }
-                            final long token = Binder.clearCallingIdentity();
-                            try {
-                                bindAppFunctionServiceUnchecked(
-                                        requestInternal,
-                                        serviceIntent,
-                                        targetUser,
-                                        safeExecuteAppFunctionCallback,
-                                        /* bindFlags= */ Context.BIND_AUTO_CREATE,
-                                        /* timeoutInMillis= */ mServiceConfig
-                                                .getExecuteAppFunctionTimeoutMillis());
-                            } finally {
-                                Binder.restoreCallingIdentity(token);
-                            }
-                        })
-                .exceptionally(
-                        ex -> {
-                            safeExecuteAppFunctionCallback.onResult(
-                                    mapExceptionToExecuteAppFunctionResponse(ex));
-                            return null;
-                        });
+        var unused =
+                mCallerValidator
+                        .verifyCallerCanExecuteAppFunction(
+                                validatedCallingPackage,
+                                targetPackageName,
+                                requestInternal.getClientRequest().getFunctionIdentifier())
+                        .thenAccept(
+                                canExecute -> {
+                                    if (!canExecute) {
+                                        safeExecuteAppFunctionCallback.onResult(
+                                                ExecuteAppFunctionResponse.newFailure(
+                                                        ExecuteAppFunctionResponse.RESULT_DENIED,
+                                                        "Caller does not have permission to execute"
+                                                                + " the appfunction",
+                                                        /* extras= */ null));
+                                        return;
+                                    }
+                                    Intent serviceIntent =
+                                            mInternalServiceHelper.resolveAppFunctionService(
+                                                    targetPackageName, targetUser);
+                                    if (serviceIntent == null) {
+                                        safeExecuteAppFunctionCallback.onResult(
+                                                ExecuteAppFunctionResponse.newFailure(
+                                                        ExecuteAppFunctionResponse
+                                                                .RESULT_INTERNAL_ERROR,
+                                                        "Cannot find the target service.",
+                                                        /* extras= */ null));
+                                        return;
+                                    }
+                                    final long token = Binder.clearCallingIdentity();
+                                    try {
+                                        bindAppFunctionServiceUnchecked(
+                                                requestInternal,
+                                                serviceIntent,
+                                                targetUser,
+                                                safeExecuteAppFunctionCallback,
+                                                /* bindFlags= */ Context.BIND_AUTO_CREATE,
+                                                /* timeoutInMillis= */ mServiceConfig
+                                                        .getExecuteAppFunctionTimeoutMillis());
+                                    } finally {
+                                        Binder.restoreCallingIdentity(token);
+                                    }
+                                })
+                        .exceptionally(
+                                ex -> {
+                                    safeExecuteAppFunctionCallback.onResult(
+                                            mapExceptionToExecuteAppFunctionResponse(ex));
+                                    return null;
+                                });
     }
 
     private void bindAppFunctionServiceUnchecked(
@@ -256,7 +288,7 @@
     }
 
     private ExecuteAppFunctionResponse mapExceptionToExecuteAppFunctionResponse(Throwable e) {
-        if(e instanceof CompletionException) {
+        if (e instanceof CompletionException) {
             e = e.getCause();
         }
 
@@ -291,4 +323,103 @@
         }
         return ExecuteAppFunctionResponse.RESULT_INTERNAL_ERROR;
     }
+
+    private void registerAppSearchObserver(@NonNull TargetUser user) {
+        AppSearchManager perUserAppSearchManager =
+                mContext.createContextAsUser(user.getUserHandle(), /* flags= */ 0)
+                        .getSystemService(AppSearchManager.class);
+        if (perUserAppSearchManager == null) {
+            Slog.d(TAG, "AppSearch Manager not found for user: " + user.getUserIdentifier());
+            return;
+        }
+        try (FutureGlobalSearchSession futureGlobalSearchSession =
+                new FutureGlobalSearchSession(
+                        perUserAppSearchManager, AppFunctionExecutors.THREAD_POOL_EXECUTOR)) {
+            AppFunctionMetadataObserver appFunctionMetadataObserver =
+                    new AppFunctionMetadataObserver(
+                            user.getUserHandle(),
+                            mContext.createContextAsUser(user.getUserHandle(), /* flags= */ 0));
+            var unused =
+                    futureGlobalSearchSession
+                            .registerObserverCallbackAsync(
+                                    "android",
+                                    new ObserverSpec.Builder().build(),
+                                    THREAD_POOL_EXECUTOR,
+                                    appFunctionMetadataObserver)
+                            .whenComplete(
+                                    (voidResult, ex) -> {
+                                        if (ex != null) {
+                                            Slog.e(TAG, "Failed to register observer: ", ex);
+                                        }
+                                    });
+
+        } catch (IOException ex) {
+            Slog.e(TAG, "Failed to close observer session: ", ex);
+        }
+    }
+
+    private void trySyncRuntimeMetadata(@NonNull TargetUser user) {
+        MetadataSyncAdapter metadataSyncAdapter =
+                MetadataSyncPerUser.getPerUserMetadataSyncAdapter(
+                        user.getUserHandle(),
+                        mContext.createContextAsUser(user.getUserHandle(), /* flags= */ 0));
+        if (metadataSyncAdapter != null) {
+            var unused =
+                    metadataSyncAdapter
+                            .submitSyncRequest()
+                            .whenComplete(
+                                    (isSuccess, ex) -> {
+                                        if (ex != null || !isSuccess) {
+                                            Slog.e(TAG, "Sync was not successful");
+                                        }
+                                    });
+        }
+    }
+
+    private static class AppFunctionMetadataObserver implements ObserverCallback {
+        @Nullable private final MetadataSyncAdapter mPerUserMetadataSyncAdapter;
+
+        AppFunctionMetadataObserver(@NonNull UserHandle userHandle, @NonNull Context userContext) {
+            mPerUserMetadataSyncAdapter =
+                    MetadataSyncPerUser.getPerUserMetadataSyncAdapter(userHandle, userContext);
+        }
+
+        @Override
+        public void onDocumentChanged(@NonNull DocumentChangeInfo documentChangeInfo) {
+            if (mPerUserMetadataSyncAdapter == null) {
+                return;
+            }
+            if (documentChangeInfo
+                            .getDatabaseName()
+                            .equals(AppFunctionStaticMetadataHelper.APP_FUNCTION_STATIC_METADATA_DB)
+                    && documentChangeInfo
+                            .getNamespace()
+                            .equals(
+                                    AppFunctionStaticMetadataHelper
+                                            .APP_FUNCTION_STATIC_NAMESPACE)) {
+                var unused = mPerUserMetadataSyncAdapter.submitSyncRequest();
+            }
+        }
+
+        @Override
+        public void onSchemaChanged(@NonNull SchemaChangeInfo schemaChangeInfo) {
+            if (mPerUserMetadataSyncAdapter == null) {
+                return;
+            }
+            if (schemaChangeInfo
+                    .getDatabaseName()
+                    .equals(AppFunctionStaticMetadataHelper.APP_FUNCTION_STATIC_METADATA_DB)) {
+                boolean shouldInitiateSync = false;
+                for (String schemaName : schemaChangeInfo.getChangedSchemaNames()) {
+                    if (schemaName.startsWith(AppFunctionStaticMetadataHelper.STATIC_SCHEMA_TYPE)) {
+                        shouldInitiateSync = true;
+                        break;
+                    }
+                }
+                if (shouldInitiateSync) {
+                    var unused = mPerUserMetadataSyncAdapter.submitSyncRequest();
+                }
+            }
+        }
+    }
 }
diff --git a/services/appfunctions/java/com/android/server/appfunctions/MetadataSyncAdapter.java b/services/appfunctions/java/com/android/server/appfunctions/MetadataSyncAdapter.java
index e2573590..8c6f50e 100644
--- a/services/appfunctions/java/com/android/server/appfunctions/MetadataSyncAdapter.java
+++ b/services/appfunctions/java/com/android/server/appfunctions/MetadataSyncAdapter.java
@@ -24,6 +24,8 @@
 import android.app.appfunctions.AppFunctionRuntimeMetadata;
 import android.app.appfunctions.AppFunctionStaticMetadataHelper;
 import android.app.appsearch.AppSearchBatchResult;
+import android.app.appsearch.AppSearchManager;
+import android.app.appsearch.AppSearchManager.SearchContext;
 import android.app.appsearch.AppSearchResult;
 import android.app.appsearch.AppSearchSchema;
 import android.app.appsearch.PackageIdentifier;
@@ -61,9 +63,8 @@
  */
 public class MetadataSyncAdapter {
     private static final String TAG = MetadataSyncAdapter.class.getSimpleName();
-    private final FutureAppSearchSession mRuntimeMetadataSearchSession;
-    private final FutureAppSearchSession mStaticMetadataSearchSession;
     private final Executor mSyncExecutor;
+    private final AppSearchManager mAppSearchManager;
     private final PackageManager mPackageManager;
 
     // Hidden constants in {@link SetSchemaRequest} that restricts runtime metadata visibility
@@ -73,13 +74,11 @@
 
     public MetadataSyncAdapter(
             @NonNull Executor syncExecutor,
-            @NonNull FutureAppSearchSession runtimeMetadataSearchSession,
-            @NonNull FutureAppSearchSession staticMetadataSearchSession,
-            @NonNull PackageManager packageManager) {
+            @NonNull PackageManager packageManager,
+            @NonNull AppSearchManager appSearchManager) {
         mSyncExecutor = Objects.requireNonNull(syncExecutor);
-        mRuntimeMetadataSearchSession = Objects.requireNonNull(runtimeMetadataSearchSession);
-        mStaticMetadataSearchSession = Objects.requireNonNull(staticMetadataSearchSession);
         mPackageManager = Objects.requireNonNull(packageManager);
+        mAppSearchManager = Objects.requireNonNull(appSearchManager);
     }
 
     /**
@@ -89,31 +88,54 @@
      *     synchronization was successful.
      */
     public AndroidFuture<Boolean> submitSyncRequest() {
+        SearchContext staticMetadataSearchContext =
+                new SearchContext.Builder(
+                                AppFunctionStaticMetadataHelper.APP_FUNCTION_STATIC_METADATA_DB)
+                        .build();
+        SearchContext runtimeMetadataSearchContext =
+                new SearchContext.Builder(
+                                AppFunctionRuntimeMetadata.APP_FUNCTION_RUNTIME_METADATA_DB)
+                        .build();
         AndroidFuture<Boolean> settableSyncStatus = new AndroidFuture<>();
         mSyncExecutor.execute(
                 () -> {
-                    try {
-                        trySyncAppFunctionMetadataBlocking();
+                    try (FutureAppSearchSession staticMetadataSearchSession =
+                                    new FutureAppSearchSessionImpl(
+                                            mAppSearchManager,
+                                            AppFunctionExecutors.THREAD_POOL_EXECUTOR,
+                                            staticMetadataSearchContext);
+                            FutureAppSearchSession runtimeMetadataSearchSession =
+                                    new FutureAppSearchSessionImpl(
+                                            mAppSearchManager,
+                                            AppFunctionExecutors.THREAD_POOL_EXECUTOR,
+                                            runtimeMetadataSearchContext)) {
+
+                        trySyncAppFunctionMetadataBlocking(
+                                staticMetadataSearchSession, runtimeMetadataSearchSession);
                         settableSyncStatus.complete(true);
-                    } catch (Exception e) {
-                        settableSyncStatus.completeExceptionally(e);
+
+                    } catch (Exception ex) {
+                        settableSyncStatus.completeExceptionally(ex);
                     }
                 });
         return settableSyncStatus;
     }
 
     @WorkerThread
-    private void trySyncAppFunctionMetadataBlocking()
+    @VisibleForTesting
+    void trySyncAppFunctionMetadataBlocking(
+            @NonNull FutureAppSearchSession staticMetadataSearchSession,
+            @NonNull FutureAppSearchSession runtimeMetadataSearchSession)
             throws ExecutionException, InterruptedException {
         ArrayMap<String, ArraySet<String>> staticPackageToFunctionMap =
                 getPackageToFunctionIdMap(
-                        mStaticMetadataSearchSession,
+                        staticMetadataSearchSession,
                         AppFunctionStaticMetadataHelper.STATIC_SCHEMA_TYPE,
                         AppFunctionStaticMetadataHelper.PROPERTY_FUNCTION_ID,
                         AppFunctionStaticMetadataHelper.PROPERTY_PACKAGE_NAME);
         ArrayMap<String, ArraySet<String>> runtimePackageToFunctionMap =
                 getPackageToFunctionIdMap(
-                        mRuntimeMetadataSearchSession,
+                        runtimeMetadataSearchSession,
                         RUNTIME_SCHEMA_TYPE,
                         AppFunctionRuntimeMetadata.PROPERTY_FUNCTION_ID,
                         AppFunctionRuntimeMetadata.PROPERTY_PACKAGE_NAME);
@@ -134,7 +156,7 @@
             RemoveByDocumentIdRequest removeByDocumentIdRequest =
                     buildRemoveRuntimeMetadataRequest(removedFunctionsDiffMap);
             AppSearchBatchResult<String, Void> removeDocumentBatchResult =
-                    mRuntimeMetadataSearchSession.remove(removeByDocumentIdRequest).get();
+                    runtimeMetadataSearchSession.remove(removeByDocumentIdRequest).get();
             if (!removeDocumentBatchResult.isSuccess()) {
                 throw convertFailedAppSearchResultToException(
                         removeDocumentBatchResult.getFailures().values());
@@ -144,13 +166,14 @@
         if (!addedFunctionsDiffMap.isEmpty()) {
             // TODO(b/357551503): only set schema on package diff
             SetSchemaRequest addSetSchemaRequest =
-                    buildSetSchemaRequestForRuntimeMetadataSchemas(appRuntimeMetadataSchemas);
+                    buildSetSchemaRequestForRuntimeMetadataSchemas(
+                            mPackageManager, appRuntimeMetadataSchemas);
             Objects.requireNonNull(
-                    mRuntimeMetadataSearchSession.setSchema(addSetSchemaRequest).get());
+                    runtimeMetadataSearchSession.setSchema(addSetSchemaRequest).get());
             PutDocumentsRequest putDocumentsRequest =
                     buildPutRuntimeMetadataRequest(addedFunctionsDiffMap);
             AppSearchBatchResult<String, Void> putDocumentBatchResult =
-                    mRuntimeMetadataSearchSession.put(putDocumentsRequest).get();
+                    runtimeMetadataSearchSession.put(putDocumentsRequest).get();
             if (!putDocumentBatchResult.isSuccess()) {
                 throw convertFailedAppSearchResultToException(
                         putDocumentBatchResult.getFailures().values());
@@ -211,6 +234,7 @@
 
     @NonNull
     private SetSchemaRequest buildSetSchemaRequestForRuntimeMetadataSchemas(
+            @NonNull PackageManager packageManager,
             @NonNull Set<AppSearchSchema> metadataSchemaSet) {
         Objects.requireNonNull(metadataSchemaSet);
         SetSchemaRequest.Builder setSchemaRequestBuilder =
@@ -220,7 +244,7 @@
             String packageName =
                     AppFunctionRuntimeMetadata.getPackageNameFromSchema(
                             runtimeMetadataSchema.getSchemaType());
-            byte[] packageCert = getCertificate(packageName);
+            byte[] packageCert = getCertificate(packageManager, packageName);
             if (packageCert == null) {
                 continue;
             }
@@ -399,13 +423,15 @@
 
     /** Gets the SHA-256 certificate from a {@link PackageManager}, or null if it is not found. */
     @Nullable
-    private byte[] getCertificate(@NonNull String packageName) {
+    private byte[] getCertificate(
+            @NonNull PackageManager packageManager, @NonNull String packageName) {
+        Objects.requireNonNull(packageManager);
         Objects.requireNonNull(packageName);
         PackageInfo packageInfo;
         try {
             packageInfo =
                     Objects.requireNonNull(
-                            mPackageManager.getPackageInfo(
+                            packageManager.getPackageInfo(
                                     packageName,
                                     PackageManager.GET_META_DATA
                                             | PackageManager.GET_SIGNING_CERTIFICATES));
diff --git a/services/appfunctions/java/com/android/server/appfunctions/MetadataSyncPerUser.java b/services/appfunctions/java/com/android/server/appfunctions/MetadataSyncPerUser.java
new file mode 100644
index 0000000..f421527
--- /dev/null
+++ b/services/appfunctions/java/com/android/server/appfunctions/MetadataSyncPerUser.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2024 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 com.android.server.appfunctions;
+
+import android.annotation.Nullable;
+import android.app.appsearch.AppSearchManager;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.UserHandle;
+import android.util.SparseArray;
+
+import com.android.internal.annotations.GuardedBy;
+
+/** A Singleton class that manages per-user metadata sync adapters. */
+public final class MetadataSyncPerUser {
+    private static final String TAG = MetadataSyncPerUser.class.getSimpleName();
+
+    /** A map of per-user adapter for synchronizing appFunction metadata. */
+    @GuardedBy("sLock")
+    private static final SparseArray<MetadataSyncAdapter> sPerUserMetadataSyncAdapter =
+            new SparseArray<>();
+
+    private static final Object sLock = new Object();
+
+    /**
+     * Returns the per-user metadata sync adapter for the given user.
+     *
+     * @param user The user for which to get the metadata sync adapter.
+     * @param userContext The user context for the given user.
+     * @return The metadata sync adapter for the given user.
+     */
+    @Nullable
+    public static MetadataSyncAdapter getPerUserMetadataSyncAdapter(
+            UserHandle user, Context userContext) {
+        synchronized (sLock) {
+            MetadataSyncAdapter metadataSyncAdapter =
+                    sPerUserMetadataSyncAdapter.get(user.getIdentifier(), null);
+            if (metadataSyncAdapter == null) {
+                AppSearchManager perUserAppSearchManager =
+                        userContext.getSystemService(AppSearchManager.class);
+                PackageManager perUserPackageManager = userContext.getPackageManager();
+                if (perUserAppSearchManager != null) {
+                    metadataSyncAdapter =
+                            new MetadataSyncAdapter(
+                                    AppFunctionExecutors.getPerUserSyncExecutor(user),
+                                    perUserPackageManager,
+                                    perUserAppSearchManager);
+                    sPerUserMetadataSyncAdapter.put(user.getIdentifier(), metadataSyncAdapter);
+                    return metadataSyncAdapter;
+                }
+            }
+            return metadataSyncAdapter;
+        }
+    }
+
+    /**
+     * Removes the per-user metadata sync adapter for the given user.
+     *
+     * @param user The user for which to remove the metadata sync adapter.
+     */
+    public static void removeUserSyncAdapter(UserHandle user) {
+        synchronized (sLock) {
+            sPerUserMetadataSyncAdapter.remove(user.getIdentifier());
+        }
+    }
+}
diff --git a/services/tests/appfunctions/src/com/android/server/appfunctions/MetadataSyncAdapterTest.kt b/services/tests/appfunctions/src/com/android/server/appfunctions/MetadataSyncAdapterTest.kt
index 63cf7bf..c05c381 100644
--- a/services/tests/appfunctions/src/com/android/server/appfunctions/MetadataSyncAdapterTest.kt
+++ b/services/tests/appfunctions/src/com/android/server/appfunctions/MetadataSyncAdapterTest.kt
@@ -139,16 +139,15 @@
         runtimeSearchSession.put(putDocumentsRequest).get()
         staticSearchSession.put(putDocumentsRequest).get()
         val metadataSyncAdapter =
-            MetadataSyncAdapter(
-                testExecutor,
-                runtimeSearchSession,
+            MetadataSyncAdapter(testExecutor, packageManager, appSearchManager)
+
+        val submitSyncRequest =
+            metadataSyncAdapter.trySyncAppFunctionMetadataBlocking(
                 staticSearchSession,
-                packageManager,
+                runtimeSearchSession,
             )
 
-        val submitSyncRequest = metadataSyncAdapter.submitSyncRequest()
-
-        assertThat(submitSyncRequest.get()).isTrue()
+        assertThat(submitSyncRequest).isInstanceOf(Unit::class.java)
     }
 
     @Test
@@ -182,16 +181,15 @@
             PutDocumentsRequest.Builder().addGenericDocuments(functionRuntimeMetadata).build()
         staticSearchSession.put(putDocumentsRequest).get()
         val metadataSyncAdapter =
-            MetadataSyncAdapter(
-                testExecutor,
-                runtimeSearchSession,
+            MetadataSyncAdapter(testExecutor, packageManager, appSearchManager)
+
+        val submitSyncRequest =
+            metadataSyncAdapter.trySyncAppFunctionMetadataBlocking(
                 staticSearchSession,
-                packageManager,
+                runtimeSearchSession,
             )
 
-        val submitSyncRequest = metadataSyncAdapter.submitSyncRequest()
-
-        assertThat(submitSyncRequest.get()).isTrue()
+        assertThat(submitSyncRequest).isInstanceOf(Unit::class.java)
     }
 
     @Test
@@ -239,16 +237,15 @@
             PutDocumentsRequest.Builder().addGenericDocuments(functionRuntimeMetadata).build()
         runtimeSearchSession.put(putDocumentsRequest).get()
         val metadataSyncAdapter =
-            MetadataSyncAdapter(
-                testExecutor,
-                runtimeSearchSession,
+            MetadataSyncAdapter(testExecutor, packageManager, appSearchManager)
+
+        val submitSyncRequest =
+            metadataSyncAdapter.trySyncAppFunctionMetadataBlocking(
                 staticSearchSession,
-                packageManager,
+                runtimeSearchSession,
             )
 
-        val submitSyncRequest = metadataSyncAdapter.submitSyncRequest()
-
-        assertThat(submitSyncRequest.get()).isTrue()
+        assertThat(submitSyncRequest).isInstanceOf(Unit::class.java)
     }
 
     @Test