Update framework from Jetpack.

Changes included:
* 579fbfb: Create a CallerAccess object to encapsulate (package, uid, hasSystemAccess).
* f431798: Send schema change notifications.

Bug: 215624105
Bug: 193494000
Test: Presubmit
Change-Id: I34f35e28ed50936e14b0d5dd572dfe106935b338
diff --git a/service/java/com/android/server/appsearch/AppSearchManagerService.java b/service/java/com/android/server/appsearch/AppSearchManagerService.java
index c16021c..91a4e2e 100644
--- a/service/java/com/android/server/appsearch/AppSearchManagerService.java
+++ b/service/java/com/android/server/appsearch/AppSearchManagerService.java
@@ -63,6 +63,7 @@
 import com.android.server.SystemService;
 import com.android.server.appsearch.external.localstorage.stats.CallStats;
 import com.android.server.appsearch.external.localstorage.stats.OptimizeStats;
+import com.android.server.appsearch.external.localstorage.visibilitystore.CallerAccess;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityStore;
 import com.android.server.appsearch.observer.AppSearchObserverProxy;
 import com.android.server.appsearch.stats.StatsCollector;
@@ -437,10 +438,12 @@
                             instance.getAppSearchImpl().getSchema(
                                     packageName,
                                     databaseName,
-                                    callingPackageName,
-                                    callingUid,
-                                    instance.getVisibilityChecker()
-                                            .doesCallerHaveSystemAccess(callingPackageName));
+                                    new CallerAccess(
+                                            callingPackageName,
+                                            callingUid,
+                                            instance.getVisibilityChecker()
+                                                    .doesCallerHaveSystemAccess(
+                                                            callingPackageName)));
                     invokeCallbackOnResult(
                             callback,
                             AppSearchResult.newSuccessfulResult(response.getBundle()));
@@ -629,10 +632,12 @@
                                         namespace,
                                         id,
                                         typePropertyPaths,
-                                        callingPackageName,
-                                        callingUid,
-                                        instance.getVisibilityChecker()
-                                                .doesCallerHaveSystemAccess(callingPackageName));
+                                        new CallerAccess(
+                                                callingPackageName,
+                                                callingUid,
+                                                instance.getVisibilityChecker()
+                                                        .doesCallerHaveSystemAccess(
+                                                                callingPackageName)));
                             } else {
                                 document = instance.getAppSearchImpl().getDocument(
                                         targetPackageName,
@@ -792,9 +797,10 @@
                     SearchResultPage searchResultPage = instance.getAppSearchImpl().globalQuery(
                             queryExpression,
                             new SearchSpec(searchSpecBundle),
-                            packageName,
-                            callingUid,
-                            callerHasSystemAccess,
+                            new CallerAccess(
+                                    packageName,
+                                    callingUid,
+                                    callerHasSystemAccess),
                             instance.getLogger());
                     ++operationSuccessCount;
                     invokeCallbackOnResult(
@@ -1351,9 +1357,11 @@
                 AppSearchUserInstance instance =
                         mAppSearchUserInstanceManager.getUserInstance(targetUser);
                 instance.getAppSearchImpl().addObserver(
-                        callingPackage,
-                        callingUid,
-                        instance.getVisibilityChecker().doesCallerHaveSystemAccess(callingPackage),
+                        new CallerAccess(
+                                callingPackage,
+                                callingUid,
+                                instance.getVisibilityChecker()
+                                        .doesCallerHaveSystemAccess(callingPackage)),
                         targetPackageName,
                         new ObserverSpec(observerSpecBundle),
                         EXECUTOR,
diff --git a/service/java/com/android/server/appsearch/external/localstorage/AppSearchImpl.java b/service/java/com/android/server/appsearch/external/localstorage/AppSearchImpl.java
index 9e8c1b1..0afce10 100644
--- a/service/java/com/android/server/appsearch/external/localstorage/AppSearchImpl.java
+++ b/service/java/com/android/server/appsearch/external/localstorage/AppSearchImpl.java
@@ -45,6 +45,7 @@
 import android.app.appsearch.observer.ObserverSpec;
 import android.app.appsearch.util.LogUtil;
 import android.os.Bundle;
+import android.os.Process;
 import android.os.SystemClock;
 import android.util.ArrayMap;
 import android.util.ArraySet;
@@ -66,6 +67,7 @@
 import com.android.server.appsearch.external.localstorage.stats.SearchStats;
 import com.android.server.appsearch.external.localstorage.stats.SetSchemaStats;
 import com.android.server.appsearch.external.localstorage.util.PrefixUtil;
+import com.android.server.appsearch.external.localstorage.visibilitystore.CallerAccess;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityChecker;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityStore;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityUtil;
@@ -472,126 +474,312 @@
         mReadWriteLock.writeLock().lock();
         try {
             throwIfClosedLocked();
-
-            SchemaProto.Builder existingSchemaBuilder = getSchemaProtoLocked().toBuilder();
-
-            SchemaProto.Builder newSchemaBuilder = SchemaProto.newBuilder();
-            for (int i = 0; i < schemas.size(); i++) {
-                AppSearchSchema schema = schemas.get(i);
-                SchemaTypeConfigProto schemaTypeProto =
-                        SchemaToProtoConverter.toSchemaTypeConfigProto(schema, version);
-                newSchemaBuilder.addTypes(schemaTypeProto);
+            if (mObserverManager.isPackageObserved(packageName)) {
+                return doSetSchemaWithChangeNotificationLocked(
+                        packageName,
+                        databaseName,
+                        schemas,
+                        visibilityDocuments,
+                        forceOverride,
+                        version,
+                        setSchemaStatsBuilder);
+            } else {
+                return doSetSchemaNoChangeNotificationLocked(
+                        packageName,
+                        databaseName,
+                        schemas,
+                        visibilityDocuments,
+                        forceOverride,
+                        version,
+                        setSchemaStatsBuilder);
             }
-
-            String prefix = createPrefix(packageName, databaseName);
-            // Combine the existing schema (which may have types from other prefixes) with this
-            // prefix's new schema. Modifies the existingSchemaBuilder.
-            RewrittenSchemaResults rewrittenSchemaResults =
-                    rewriteSchema(prefix, existingSchemaBuilder, newSchemaBuilder.build());
-
-            // Apply schema
-            SchemaProto finalSchema = existingSchemaBuilder.build();
-            mLogUtil.piiTrace("setSchema, request", finalSchema.getTypesCount(), finalSchema);
-            SetSchemaResultProto setSchemaResultProto =
-                    mIcingSearchEngineLocked.setSchema(finalSchema, forceOverride);
-            mLogUtil.piiTrace(
-                    "setSchema, response", setSchemaResultProto.getStatus(), setSchemaResultProto);
-
-            if (setSchemaStatsBuilder != null) {
-                setSchemaStatsBuilder.setStatusCode(
-                        statusProtoToResultCode(setSchemaResultProto.getStatus()));
-                AppSearchLoggerHelper.copyNativeStats(setSchemaResultProto, setSchemaStatsBuilder);
-            }
-
-            // Determine whether it succeeded.
-            try {
-                checkSuccess(setSchemaResultProto.getStatus());
-            } catch (AppSearchException e) {
-                // Swallow the exception for the incompatible change case. We will propagate
-                // those deleted schemas and incompatible types to the SetSchemaResponse.
-                boolean isFailedPrecondition =
-                        setSchemaResultProto.getStatus().getCode()
-                                == StatusProto.Code.FAILED_PRECONDITION;
-                boolean isIncompatible =
-                        setSchemaResultProto.getDeletedSchemaTypesCount() > 0
-                                || setSchemaResultProto.getIncompatibleSchemaTypesCount() > 0;
-                if (isFailedPrecondition && isIncompatible) {
-                    return SetSchemaResponseToProtoConverter.toSetSchemaResponse(
-                            setSchemaResultProto, prefix);
-                } else {
-                    throw e;
-                }
-            }
-
-            // Update derived data structures.
-            for (SchemaTypeConfigProto schemaTypeConfigProto :
-                    rewrittenSchemaResults.mRewrittenPrefixedTypes.values()) {
-                addToMap(mSchemaMapLocked, prefix, schemaTypeConfigProto);
-            }
-
-            for (String schemaType : rewrittenSchemaResults.mDeletedPrefixedTypes) {
-                removeFromMap(mSchemaMapLocked, prefix, schemaType);
-            }
-            // Since the constructor of VisibilityStore will set schema. Avoid call visibility
-            // store before we have already created it.
-            if (mVisibilityStoreLocked != null) {
-                // Add prefix to all visibility documents.
-                List<VisibilityDocument> prefixedVisibilityDocuments =
-                        new ArrayList<>(visibilityDocuments.size());
-                // Find out which Visibility document is deleted or changed to all-default settings.
-                // We need to remove them from Visibility Store.
-                Set<String> deprecatedVisibilityDocuments =
-                        new ArraySet<>(rewrittenSchemaResults.mRewrittenPrefixedTypes.keySet());
-                for (int i = 0; i < visibilityDocuments.size(); i++) {
-                    VisibilityDocument unPrefixedDocument = visibilityDocuments.get(i);
-                    // The VisibilityDocument is controlled by the client and it's untrusted but we
-                    // make it safe by appending a prefix.
-                    // We must control the package-database prefix. Therefore even if the client
-                    // fake the id, they can only mess their own app. That's totally allowed and
-                    // they can do this via the public API too.
-                    String prefixedSchemaType = prefix + unPrefixedDocument.getId();
-                    prefixedVisibilityDocuments.add(
-                            new VisibilityDocument(
-                                    unPrefixedDocument.toBuilder()
-                                            .setId(prefixedSchemaType)
-                                            .build()));
-                    // This schema has visibility settings. We should keep it from the removal list.
-                    deprecatedVisibilityDocuments.remove(prefixedSchemaType);
-                }
-                // Now deprecatedVisibilityDocuments contains those existing schemas that has
-                // all-default visibility settings, add deleted schemas. That's all we need to
-                // remove.
-                deprecatedVisibilityDocuments.addAll(rewrittenSchemaResults.mDeletedPrefixedTypes);
-                mVisibilityStoreLocked.removeVisibility(deprecatedVisibilityDocuments);
-                mVisibilityStoreLocked.setVisibility(prefixedVisibilityDocuments);
-            }
-            return SetSchemaResponseToProtoConverter.toSetSchemaResponse(
-                    setSchemaResultProto, prefix);
         } finally {
             mReadWriteLock.writeLock().unlock();
         }
     }
 
     /**
+     * Updates the AppSearch schema for this app, dispatching change notifications.
+     *
+     * @see #setSchema
+     * @see #doSetSchemaNoChangeNotificationLocked
+     */
+    @GuardedBy("mReadWriteLock")
+    @NonNull
+    private SetSchemaResponse doSetSchemaWithChangeNotificationLocked(
+            @NonNull String packageName,
+            @NonNull String databaseName,
+            @NonNull List<AppSearchSchema> schemas,
+            @NonNull List<VisibilityDocument> visibilityDocuments,
+            boolean forceOverride,
+            int version,
+            @Nullable SetSchemaStats.Builder setSchemaStatsBuilder)
+            throws AppSearchException {
+        // First, capture the old state of the system. This includes the old schema as well as
+        // whether each registered observer can access each type. Once VisibilityStore is updated
+        // by the setSchema call, the information of which observers could see which types will be
+        // lost.
+        GetSchemaResponse oldSchema =
+                getSchema(
+                        packageName,
+                        databaseName,
+                        // A CallerAccess object for internal use that has local access to this
+                        // database.
+                        new CallerAccess(
+                                /*callingPackageName=*/ packageName,
+                                // The below two settings don't matter since callingPackageName
+                                // matches
+                                /*callingUid=*/ Process.INVALID_UID,
+                                /*callerHasSystemAccess=*/ false));
+
+        // Cache some lookup tables to help us work with the old schema
+        Set<AppSearchSchema> oldSchemaTypes = oldSchema.getSchemas();
+        Map<String, AppSearchSchema> oldSchemaNameToType = new ArrayMap<>(oldSchemaTypes.size());
+        // Maps unprefixed schema name to the set of listening packages that had visibility into
+        // that type under the old schema.
+        Map<String, Set<String>> oldSchemaNameToVisibleListeningPackage =
+                new ArrayMap<>(oldSchemaTypes.size());
+        for (AppSearchSchema oldSchemaType : oldSchemaTypes) {
+            String oldSchemaName = oldSchemaType.getSchemaType();
+            oldSchemaNameToType.put(oldSchemaName, oldSchemaType);
+            oldSchemaNameToVisibleListeningPackage.put(
+                    oldSchemaName,
+                    mObserverManager.getObserversForSchemaType(
+                            packageName,
+                            databaseName,
+                            oldSchemaName,
+                            mVisibilityStoreLocked,
+                            mVisibilityCheckerLocked));
+        }
+
+        // Apply the new schema
+        SetSchemaResponse setSchemaResponse =
+                doSetSchemaNoChangeNotificationLocked(
+                        packageName,
+                        databaseName,
+                        schemas,
+                        visibilityDocuments,
+                        forceOverride,
+                        version,
+                        setSchemaStatsBuilder);
+
+        // Cache some lookup tables to help us work with the new schema
+        Map<String, AppSearchSchema> newSchemaNameToType = new ArrayMap<>(schemas.size());
+        // Maps unprefixed schema name to the set of listening packages that have visibility into
+        // that type under the new schema.
+        Map<String, Set<String>> newSchemaNameToVisibleListeningPackage =
+                new ArrayMap<>(schemas.size());
+        for (AppSearchSchema newSchemaType : schemas) {
+            String newSchemaName = newSchemaType.getSchemaType();
+            newSchemaNameToType.put(newSchemaName, newSchemaType);
+            newSchemaNameToVisibleListeningPackage.put(
+                    newSchemaName,
+                    mObserverManager.getObserversForSchemaType(
+                            packageName,
+                            databaseName,
+                            newSchemaName,
+                            mVisibilityStoreLocked,
+                            mVisibilityCheckerLocked));
+        }
+
+        // Create a unified set of all schema names mentioned in either the old or new schema.
+        Set<String> allSchemaNames = new ArraySet<>(oldSchemaNameToType.keySet());
+        allSchemaNames.addAll(newSchemaNameToType.keySet());
+
+        // Perform the diff between the old and new schema.
+        for (String schemaName : allSchemaNames) {
+            final AppSearchSchema contentBefore = oldSchemaNameToType.get(schemaName);
+            final AppSearchSchema contentAfter = newSchemaNameToType.get(schemaName);
+
+            final boolean existBefore = (contentBefore != null);
+            final boolean existAfter = (contentAfter != null);
+
+            // This should never happen
+            if (!existBefore && !existAfter) {
+                continue;
+            }
+
+            boolean contentsChanged = true;
+            if (existBefore && existAfter && contentBefore.equals(contentAfter)) {
+                contentsChanged = false;
+            }
+
+            Set<String> oldVisibleListeners =
+                    oldSchemaNameToVisibleListeningPackage.get(schemaName);
+            Set<String> newVisibleListeners =
+                    newSchemaNameToVisibleListeningPackage.get(schemaName);
+            Set<String> allListeningPackages = new ArraySet<>(oldVisibleListeners);
+            if (newVisibleListeners != null) {
+                allListeningPackages.addAll(newVisibleListeners);
+            }
+
+            // Now that we've computed the relationship between the old and new schema, we go
+            // observer by observer and consider the observer's own personal view of the schema.
+            for (String listeningPackageName : allListeningPackages) {
+                // Figure out the visibility
+                final boolean visibleBefore =
+                        (existBefore
+                                && oldVisibleListeners != null
+                                && oldVisibleListeners.contains(listeningPackageName));
+                final boolean visibleAfter =
+                        (existAfter
+                                && newVisibleListeners != null
+                                && newVisibleListeners.contains(listeningPackageName));
+
+                // Now go through the truth table of all the relevant flags.
+                // visibleBefore and visibleAfter take into account existBefore and existAfter, so
+                // we can stop worrying about existBefore and existAfter.
+                boolean sendNotification = false;
+                if (visibleBefore && visibleAfter && contentsChanged) {
+                    sendNotification = true; // Type configuration was modified
+                } else if (!visibleBefore && visibleAfter) {
+                    sendNotification = true; // Newly granted visibility or type was created
+                } else if (visibleBefore && !visibleAfter) {
+                    sendNotification = true; // Revoked visibility or type was deleted
+                } else {
+                    // No visibility before and no visibility after. Nothing to dispatch.
+                }
+
+                if (sendNotification) {
+                    mObserverManager.onSchemaChange(
+                            /*listeningPackageName=*/ listeningPackageName,
+                            /*targetPackageName=*/ packageName,
+                            /*databaseName=*/ databaseName,
+                            /*schemaName=*/ schemaName);
+                }
+            }
+        }
+
+        return setSchemaResponse;
+    }
+
+    /**
+     * Updates the AppSearch schema for this app, without dispatching change notifications.
+     *
+     * <p>This method can be used only when no one is observing {@code packageName}.
+     *
+     * @see #setSchema
+     * @see #doSetSchemaWithChangeNotificationLocked
+     */
+    @GuardedBy("mReadWriteLock")
+    @NonNull
+    private SetSchemaResponse doSetSchemaNoChangeNotificationLocked(
+            @NonNull String packageName,
+            @NonNull String databaseName,
+            @NonNull List<AppSearchSchema> schemas,
+            @NonNull List<VisibilityDocument> visibilityDocuments,
+            boolean forceOverride,
+            int version,
+            @Nullable SetSchemaStats.Builder setSchemaStatsBuilder)
+            throws AppSearchException {
+        SchemaProto.Builder existingSchemaBuilder = getSchemaProtoLocked().toBuilder();
+
+        SchemaProto.Builder newSchemaBuilder = SchemaProto.newBuilder();
+        for (int i = 0; i < schemas.size(); i++) {
+            AppSearchSchema schema = schemas.get(i);
+            SchemaTypeConfigProto schemaTypeProto =
+                    SchemaToProtoConverter.toSchemaTypeConfigProto(schema, version);
+            newSchemaBuilder.addTypes(schemaTypeProto);
+        }
+
+        String prefix = createPrefix(packageName, databaseName);
+        // Combine the existing schema (which may have types from other prefixes) with this
+        // prefix's new schema. Modifies the existingSchemaBuilder.
+        RewrittenSchemaResults rewrittenSchemaResults =
+                rewriteSchema(prefix, existingSchemaBuilder, newSchemaBuilder.build());
+
+        // Apply schema
+        SchemaProto finalSchema = existingSchemaBuilder.build();
+        mLogUtil.piiTrace("setSchema, request", finalSchema.getTypesCount(), finalSchema);
+        SetSchemaResultProto setSchemaResultProto =
+                mIcingSearchEngineLocked.setSchema(finalSchema, forceOverride);
+        mLogUtil.piiTrace(
+                "setSchema, response", setSchemaResultProto.getStatus(), setSchemaResultProto);
+
+        if (setSchemaStatsBuilder != null) {
+            setSchemaStatsBuilder.setStatusCode(
+                    statusProtoToResultCode(setSchemaResultProto.getStatus()));
+            AppSearchLoggerHelper.copyNativeStats(setSchemaResultProto, setSchemaStatsBuilder);
+        }
+
+        // Determine whether it succeeded.
+        try {
+            checkSuccess(setSchemaResultProto.getStatus());
+        } catch (AppSearchException e) {
+            // Swallow the exception for the incompatible change case. We will propagate
+            // those deleted schemas and incompatible types to the SetSchemaResponse.
+            boolean isFailedPrecondition =
+                    setSchemaResultProto.getStatus().getCode()
+                            == StatusProto.Code.FAILED_PRECONDITION;
+            boolean isIncompatible =
+                    setSchemaResultProto.getDeletedSchemaTypesCount() > 0
+                            || setSchemaResultProto.getIncompatibleSchemaTypesCount() > 0;
+            if (isFailedPrecondition && isIncompatible) {
+                return SetSchemaResponseToProtoConverter.toSetSchemaResponse(
+                        setSchemaResultProto, prefix);
+            } else {
+                throw e;
+            }
+        }
+
+        // Update derived data structures.
+        for (SchemaTypeConfigProto schemaTypeConfigProto :
+                rewrittenSchemaResults.mRewrittenPrefixedTypes.values()) {
+            addToMap(mSchemaMapLocked, prefix, schemaTypeConfigProto);
+        }
+
+        for (String schemaType : rewrittenSchemaResults.mDeletedPrefixedTypes) {
+            removeFromMap(mSchemaMapLocked, prefix, schemaType);
+        }
+        // Since the constructor of VisibilityStore will set schema. Avoid call visibility
+        // store before we have already created it.
+        if (mVisibilityStoreLocked != null) {
+            // Add prefix to all visibility documents.
+            List<VisibilityDocument> prefixedVisibilityDocuments =
+                    new ArrayList<>(visibilityDocuments.size());
+            // Find out which Visibility document is deleted or changed to all-default settings.
+            // We need to remove them from Visibility Store.
+            Set<String> deprecatedVisibilityDocuments =
+                    new ArraySet<>(rewrittenSchemaResults.mRewrittenPrefixedTypes.keySet());
+            for (int i = 0; i < visibilityDocuments.size(); i++) {
+                VisibilityDocument unPrefixedDocument = visibilityDocuments.get(i);
+                // The VisibilityDocument is controlled by the client and it's untrusted but we
+                // make it safe by appending a prefix.
+                // We must control the package-database prefix. Therefore even if the client
+                // fake the id, they can only mess their own app. That's totally allowed and
+                // they can do this via the public API too.
+                String prefixedSchemaType = prefix + unPrefixedDocument.getId();
+                prefixedVisibilityDocuments.add(
+                        new VisibilityDocument(
+                                unPrefixedDocument.toBuilder().setId(prefixedSchemaType).build()));
+                // This schema has visibility settings. We should keep it from the removal list.
+                deprecatedVisibilityDocuments.remove(prefixedSchemaType);
+            }
+            // Now deprecatedVisibilityDocuments contains those existing schemas that has
+            // all-default visibility settings, add deleted schemas. That's all we need to
+            // remove.
+            deprecatedVisibilityDocuments.addAll(rewrittenSchemaResults.mDeletedPrefixedTypes);
+            mVisibilityStoreLocked.removeVisibility(deprecatedVisibilityDocuments);
+            mVisibilityStoreLocked.setVisibility(prefixedVisibilityDocuments);
+        }
+        return SetSchemaResponseToProtoConverter.toSetSchemaResponse(setSchemaResultProto, prefix);
+    }
+
+    /**
      * Retrieves the AppSearch schema for this package name, database.
      *
      * <p>This method belongs to query group.
      *
-     * @param callerPackageName Package name of the calling app
      * @param packageName Package that owns the requested {@link AppSearchSchema} instances.
      * @param databaseName Database that owns the requested {@link AppSearchSchema} instances.
+     * @param callerAccess Visibility access info of the calling app
      * @throws AppSearchException on IcingSearchEngine error.
      */
-    // TODO(b/215624105): The combination of (callerPackageName, callerUid, callerHasSystemAccess)
-    //  occurs together in many places related to visibility. Should these be combined into a struct
-    //  called something like CallerAccess?
     @NonNull
     public GetSchemaResponse getSchema(
             @NonNull String packageName,
             @NonNull String databaseName,
-            @NonNull String callerPackageName,
-            int callerUid,
-            boolean callerHasSystemAccess)
+            @NonNull CallerAccess callerAccess)
             throws AppSearchException {
         mReadWriteLock.readLock().lock();
         try {
@@ -611,9 +799,7 @@
                     continue;
                 }
                 if (!VisibilityUtil.isSchemaSearchableByCaller(
-                        callerPackageName,
-                        callerUid,
-                        callerHasSystemAccess,
+                        callerAccess,
                         packageName,
                         prefixedSchemaType,
                         mVisibilityStoreLocked,
@@ -892,22 +1078,18 @@
      * @param id The ID of the document to get.
      * @param typePropertyPaths A map of schema type to a list of property paths to return in the
      *     result.
-     * @param callerPackageName The package name of the caller application
-     * @param callerUid The ID of the caller application
-     * @param callerHasSystemAccess A boolean signifying if the caller has system access
+     * @param callerAccess Visibility access info of the calling app
      * @return The Document contents
      * @throws AppSearchException on IcingSearchEngine error or invalid permissions
      */
-    @Nullable
+    @NonNull
     public GenericDocument globalGetDocument(
             @NonNull String packageName,
             @NonNull String databaseName,
             @NonNull String namespace,
             @NonNull String id,
             @NonNull Map<String, List<String>> typePropertyPaths,
-            @NonNull String callerPackageName,
-            int callerUid,
-            boolean callerHasSystemAccess)
+            @NonNull CallerAccess callerAccess)
             throws AppSearchException {
         mReadWriteLock.readLock().lock();
         try {
@@ -921,9 +1103,7 @@
                                 packageName, databaseName, namespace, id, typePropertyPaths);
 
                 if (!VisibilityUtil.isSchemaSearchableByCaller(
-                        callerPackageName,
-                        callerUid,
-                        callerHasSystemAccess,
+                        callerAccess,
                         packageName,
                         documentProto.getSchema(),
                         mVisibilityStoreLocked,
@@ -969,7 +1149,6 @@
             @NonNull String id,
             @NonNull Map<String, List<String>> typePropertyPaths)
             throws AppSearchException {
-
         mReadWriteLock.readLock().lock();
         try {
             throwIfClosedLocked();
@@ -1123,11 +1302,7 @@
      *
      * @param queryExpression Query String to search.
      * @param searchSpec Spec for setting filters, raw query etc.
-     * @param callerPackageName Package name of the caller, should belong to the {@code
-     *     callerUserHandle}.
-     * @param callerUid UID of the client making the globalQuery call.
-     * @param callerHasSystemAccess Whether the caller has been positively identified as having
-     *     access to schemas marked system surfaceable.
+     * @param callerAccess Visibility access info of the calling app
      * @param logger logger to collect globalQuery stats
      * @return The results of performing this search. It may contain an empty list of results if no
      *     documents matched the query.
@@ -1137,16 +1312,16 @@
     public SearchResultPage globalQuery(
             @NonNull String queryExpression,
             @NonNull SearchSpec searchSpec,
-            @NonNull String callerPackageName,
-            int callerUid,
-            boolean callerHasSystemAccess,
+            @NonNull CallerAccess callerAccess,
             @Nullable AppSearchLogger logger)
             throws AppSearchException {
         long totalLatencyStartMillis = SystemClock.elapsedRealtime();
         SearchStats.Builder sStatsBuilder = null;
         if (logger != null) {
             sStatsBuilder =
-                    new SearchStats.Builder(SearchStats.VISIBILITY_SCOPE_GLOBAL, callerPackageName);
+                    new SearchStats.Builder(
+                            SearchStats.VISIBILITY_SCOPE_GLOBAL,
+                            callerAccess.getCallingPackageName());
         }
 
         mReadWriteLock.readLock().lock();
@@ -1175,11 +1350,7 @@
                             searchSpec, prefixFilters, mNamespaceMapLocked, mSchemaMapLocked);
             // Remove those inaccessible schemas.
             searchSpecToProtoConverter.removeInaccessibleSchemaFilter(
-                    callerPackageName,
-                    callerUid,
-                    callerHasSystemAccess,
-                    mVisibilityStoreLocked,
-                    mVisibilityCheckerLocked);
+                    callerAccess, mVisibilityStoreLocked, mVisibilityCheckerLocked);
             if (searchSpecToProtoConverter.isNothingToSearch()) {
                 // there is nothing to search over given their search filters, so we can return an
                 // empty SearchResult and skip sending request to Icing.
@@ -1187,7 +1358,8 @@
             }
             SearchResultPage searchResultPage =
                     doQueryLocked(queryExpression, searchSpecToProtoConverter, sStatsBuilder);
-            addNextPageToken(callerPackageName, searchResultPage.getNextPageToken());
+            addNextPageToken(
+                    callerAccess.getCallingPackageName(), searchResultPage.getNextPageToken());
             return searchResultPage;
         } finally {
             mReadWriteLock.readLock().unlock();
@@ -2225,10 +2397,8 @@
      * will not queue behind I/O. Therefore it is safe to call from any thread including UI or
      * binder threads.
      *
-     * @param listeningPackageName The package name of the app that wants to receive notifications.
-     * @param listeningUid The uid of the app that wants to receive notifications.
-     * @param listeningPackageHasSystemAccess Whether the app that wants to receive notifications
-     *     has access to schema types marked 'visible to system'.
+     * @param listeningPackageAccess Visibility information about the app that wants to receive
+     *     notifications.
      * @param targetPackageName The package that owns the data the observer wants to be notified
      *     for.
      * @param spec Describes the kind of data changes the observer should trigger for.
@@ -2237,9 +2407,7 @@
      * @param observer The callback to trigger on notifications.
      */
     public void addObserver(
-            @NonNull String listeningPackageName,
-            int listeningUid,
-            boolean listeningPackageHasSystemAccess,
+            @NonNull CallerAccess listeningPackageAccess,
             @NonNull String targetPackageName,
             @NonNull ObserverSpec spec,
             @NonNull Executor executor,
@@ -2249,13 +2417,7 @@
         // being created or removed. If we only registered observer for existing types, it would
         // be impossible to ever dispatch a notification of a type being added.
         mObserverManager.addObserver(
-                listeningPackageName,
-                listeningUid,
-                listeningPackageHasSystemAccess,
-                targetPackageName,
-                spec,
-                executor,
-                observer);
+                listeningPackageAccess, targetPackageName, spec, executor, observer);
     }
 
     /**
diff --git a/service/java/com/android/server/appsearch/external/localstorage/ObserverManager.java b/service/java/com/android/server/appsearch/external/localstorage/ObserverManager.java
index ecc7f90..bbd2680 100644
--- a/service/java/com/android/server/appsearch/external/localstorage/ObserverManager.java
+++ b/service/java/com/android/server/appsearch/external/localstorage/ObserverManager.java
@@ -21,17 +21,20 @@
 import android.app.appsearch.observer.AppSearchObserverCallback;
 import android.app.appsearch.observer.DocumentChangeInfo;
 import android.app.appsearch.observer.ObserverSpec;
+import android.app.appsearch.observer.SchemaChangeInfo;
 import android.util.ArrayMap;
 import android.util.ArraySet;
 import android.util.Log;
 
 import com.android.internal.annotations.GuardedBy;
 import com.android.server.appsearch.external.localstorage.util.PrefixUtil;
+import com.android.server.appsearch.external.localstorage.visibilitystore.CallerAccess;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityChecker;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityStore;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityUtil;
 
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -86,26 +89,22 @@
 
     private static final class ObserverInfo {
         /** The package which registered the observer. */
-        final String mListeningPackageName;
+        final CallerAccess mListeningPackageAccess;
 
-        final int mListeningUid;
-        final boolean mListeningPackageHasSystemAccess;
         final ObserverSpec mObserverSpec;
         final Executor mExecutor;
         final AppSearchObserverCallback mObserver;
         // Values is a set of document IDs
         volatile Map<DocumentChangeGroupKey, Set<String>> mDocumentChanges = new ArrayMap<>();
+        // Keys are database prefixes, values are a set of schema names
+        volatile Map<String, Set<String>> mSchemaChanges = new ArrayMap<>();
 
         ObserverInfo(
-                @NonNull String listeningPackageName,
-                int listeningUid,
-                boolean listeningPackageHasSystemAccess,
+                @NonNull CallerAccess listeningPackageAccess,
                 @NonNull ObserverSpec observerSpec,
                 @NonNull Executor executor,
                 @NonNull AppSearchObserverCallback observer) {
-            mListeningPackageName = Objects.requireNonNull(listeningPackageName);
-            mListeningUid = listeningUid;
-            mListeningPackageHasSystemAccess = listeningPackageHasSystemAccess;
+            mListeningPackageAccess = Objects.requireNonNull(listeningPackageAccess);
             mObserverSpec = Objects.requireNonNull(observerSpec);
             mExecutor = Objects.requireNonNull(executor);
             mObserver = Objects.requireNonNull(observer);
@@ -114,7 +113,7 @@
 
     private final Object mLock = new Object();
 
-    /** Maps observed package to observer infos watching something in that package. */
+    /** Maps target packages to ObserverInfos watching something in that package. */
     @GuardedBy("mLock")
     private final Map<String, List<ObserverInfo>> mObserversLocked = new ArrayMap<>();
 
@@ -137,10 +136,8 @@
      * will not queue behind I/O. Therefore it is safe to call from any thread including UI or
      * binder threads.
      *
-     * @param listeningPackageName The package name of the app that wants to receive notifications.
-     * @param listeningUid The uid of the app that wants to receive notifications.
-     * @param listeningPackageHasSystemAccess Whether the app that wants to receive notifications
-     *     has access to schema types marked 'visible to system'.
+     * @param listeningPackageAccess Visibility information about the app that wants to receive
+     *     notifications.
      * @param targetPackageName The package that owns the data the observer wants to be notified
      *     for.
      * @param spec Describes the kind of data changes the observer should trigger for.
@@ -149,9 +146,7 @@
      * @param observer The callback to trigger on notifications.
      */
     public void addObserver(
-            @NonNull String listeningPackageName,
-            int listeningUid,
-            boolean listeningPackageHasSystemAccess,
+            @NonNull CallerAccess listeningPackageAccess,
             @NonNull String targetPackageName,
             @NonNull ObserverSpec spec,
             @NonNull Executor executor,
@@ -162,14 +157,7 @@
                 infos = new ArrayList<>();
                 mObserversLocked.put(targetPackageName, infos);
             }
-            infos.add(
-                    new ObserverInfo(
-                            listeningPackageName,
-                            listeningUid,
-                            listeningPackageHasSystemAccess,
-                            spec,
-                            executor,
-                            observer));
+            infos.add(new ObserverInfo(listeningPackageAccess, spec, executor, observer));
         }
     }
 
@@ -220,18 +208,15 @@
                 return; // No observers for this type
             }
             // Enqueue changes for later dispatch once the call returns
+            String prefixedSchema = PrefixUtil.createPrefix(packageName, databaseName) + schemaType;
             DocumentChangeGroupKey key = null;
             for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
                 ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
                 if (!matchesSpec(schemaType, observerInfo.mObserverSpec)) {
                     continue; // Observer doesn't want this notification
                 }
-                String prefixedSchema =
-                        PrefixUtil.createPrefix(packageName, databaseName) + schemaType;
                 if (!VisibilityUtil.isSchemaSearchableByCaller(
-                        /*callerPackageName=*/ observerInfo.mListeningPackageName,
-                        observerInfo.mListeningUid,
-                        observerInfo.mListeningPackageHasSystemAccess,
+                        /*callerAccess=*/ observerInfo.mListeningPackageAccess,
                         /*targetPackageName=*/ packageName,
                         /*prefixedSchema=*/ prefixedSchema,
                         visibilityStore,
@@ -254,6 +239,60 @@
         }
     }
 
+    /**
+     * Enqueues a change to a schema type for a single observer.
+     *
+     * <p>The notification will be queued in memory for later dispatch. You must call {@link
+     * #dispatchAndClearPendingNotifications} to dispatch all such pending notifications.
+     *
+     * <p>Note that unlike {@link #onDocumentChange}, the changes reported here are not dropped for
+     * observers that don't have visibility. This is because the observer might have had visibility
+     * before the schema change, and a final deletion needs to be sent to it. Caller is responsible
+     * for checking visibility of these notifications.
+     *
+     * @param listeningPackageName Name of package that subscribed to notifications and has been
+     *     validated by the caller to have the right access to receive this notification.
+     * @param targetPackageName Name of package that owns the changed schema types.
+     * @param databaseName Database in which the changed schema types reside.
+     * @param schemaName Unprefixed name of the changed schema type.
+     */
+    public void onSchemaChange(
+            @NonNull String listeningPackageName,
+            @NonNull String targetPackageName,
+            @NonNull String databaseName,
+            @NonNull String schemaName) {
+        synchronized (mLock) {
+            List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(targetPackageName);
+            if (allObserverInfosForPackage == null || allObserverInfosForPackage.isEmpty()) {
+                return; // No observers for this type
+            }
+            // Enqueue changes for later dispatch once the call returns
+            String prefix = null;
+            for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
+                ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
+                if (!observerInfo
+                        .mListeningPackageAccess
+                        .getCallingPackageName()
+                        .equals(listeningPackageName)) {
+                    continue; // Not the observer we've been requested to update right now.
+                }
+                if (!matchesSpec(schemaName, observerInfo.mObserverSpec)) {
+                    continue; // Observer doesn't want this notification
+                }
+                if (prefix == null) {
+                    prefix = PrefixUtil.createPrefix(targetPackageName, databaseName);
+                }
+                Set<String> changedSchemaNames = observerInfo.mSchemaChanges.get(prefix);
+                if (changedSchemaNames == null) {
+                    changedSchemaNames = new ArraySet<>();
+                    observerInfo.mSchemaChanges.put(prefix, changedSchemaNames);
+                }
+                changedSchemaNames.add(schemaName);
+            }
+            mHasNotifications = true;
+        }
+    }
+
     /** Returns whether there are any observers registered to watch the given package. */
     public boolean isPackageObserved(@NonNull String packageName) {
         synchronized (mLock) {
@@ -281,6 +320,44 @@
         }
     }
 
+    /**
+     * Returns package names of listening packages registered for changes on the given {@code
+     * packageName}, {@code databaseName} and unprefixed {@code schemaType}, only if they have
+     * access to that type according to the provided {@code visibilityChecker}.
+     */
+    @NonNull
+    public Set<String> getObserversForSchemaType(
+            @NonNull String packageName,
+            @NonNull String databaseName,
+            @NonNull String schemaType,
+            @Nullable VisibilityStore visibilityStore,
+            @Nullable VisibilityChecker visibilityChecker) {
+        synchronized (mLock) {
+            List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(packageName);
+            if (allObserverInfosForPackage == null) {
+                return Collections.emptySet();
+            }
+            Set<String> result = new ArraySet<>();
+            String prefixedSchema = PrefixUtil.createPrefix(packageName, databaseName) + schemaType;
+            for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
+                ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
+                if (!matchesSpec(schemaType, observerInfo.mObserverSpec)) {
+                    continue; // Observer doesn't want this notification
+                }
+                if (!VisibilityUtil.isSchemaSearchableByCaller(
+                        /*callerAccess=*/ observerInfo.mListeningPackageAccess,
+                        /*targetPackageName=*/ packageName,
+                        /*prefixedSchema=*/ prefixedSchema,
+                        visibilityStore,
+                        visibilityChecker)) {
+                    continue; // Observer can't have this notification.
+                }
+                result.add(observerInfo.mListeningPackageAccess.getCallingPackageName());
+            }
+            return result;
+        }
+    }
+
     /** Returns whether any notifications have been queued for dispatch. */
     public boolean hasNotifications() {
         return mHasNotifications;
@@ -308,33 +385,63 @@
     @GuardedBy("mLock")
     private void dispatchAndClearPendingNotificationsLocked(@NonNull ObserverInfo observerInfo) {
         // Get and clear the pending changes
+        Map<String, Set<String>> schemaChanges = observerInfo.mSchemaChanges;
         Map<DocumentChangeGroupKey, Set<String>> documentChanges = observerInfo.mDocumentChanges;
-        if (documentChanges.isEmpty()) {
+        if (schemaChanges.isEmpty() && documentChanges.isEmpty()) {
             return;
         }
-        observerInfo.mDocumentChanges = new ArrayMap<>();
+        if (!schemaChanges.isEmpty()) {
+            observerInfo.mSchemaChanges = new ArrayMap<>();
+        }
+        if (!documentChanges.isEmpty()) {
+            observerInfo.mDocumentChanges = new ArrayMap<>();
+        }
 
         // Dispatch the pending changes
         observerInfo.mExecutor.execute(
                 () -> {
-                    for (Map.Entry<DocumentChangeGroupKey, Set<String>> entry :
-                            documentChanges.entrySet()) {
-                        DocumentChangeInfo documentChangeInfo =
-                                new DocumentChangeInfo(
-                                        entry.getKey().mPackageName,
-                                        entry.getKey().mDatabaseName,
-                                        entry.getKey().mNamespace,
-                                        entry.getKey().mSchemaName,
-                                        entry.getValue());
+                    // Schema changes
+                    if (!schemaChanges.isEmpty()) {
+                        for (Map.Entry<String, Set<String>> entry : schemaChanges.entrySet()) {
+                            SchemaChangeInfo schemaChangeInfo =
+                                    new SchemaChangeInfo(
+                                            /*packageName=*/ PrefixUtil.getPackageName(
+                                                    entry.getKey()),
+                                            /*databaseName=*/ PrefixUtil.getDatabaseName(
+                                                    entry.getKey()),
+                                            /*changedSchemaNames=*/ entry.getValue());
 
-                        try {
-                            // TODO(b/193494000): Add code to dispatch SchemaChangeInfo too.
-                            observerInfo.mObserver.onDocumentChanged(documentChangeInfo);
-                        } catch (Throwable t) {
-                            Log.w(
-                                    TAG,
-                                    "AppSearchObserverCallback threw exception during dispatch",
-                                    t);
+                            try {
+                                observerInfo.mObserver.onSchemaChanged(schemaChangeInfo);
+                            } catch (Throwable t) {
+                                Log.w(
+                                        TAG,
+                                        "AppSearchObserverCallback threw exception during dispatch",
+                                        t);
+                            }
+                        }
+                    }
+
+                    // Document changes
+                    if (!documentChanges.isEmpty()) {
+                        for (Map.Entry<DocumentChangeGroupKey, Set<String>> entry :
+                                documentChanges.entrySet()) {
+                            DocumentChangeInfo documentChangeInfo =
+                                    new DocumentChangeInfo(
+                                            entry.getKey().mPackageName,
+                                            entry.getKey().mDatabaseName,
+                                            entry.getKey().mNamespace,
+                                            entry.getKey().mSchemaName,
+                                            entry.getValue());
+
+                            try {
+                                observerInfo.mObserver.onDocumentChanged(documentChangeInfo);
+                            } catch (Throwable t) {
+                                Log.w(
+                                        TAG,
+                                        "AppSearchObserverCallback threw exception during dispatch",
+                                        t);
+                            }
                         }
                     }
                 });
diff --git a/service/java/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverter.java b/service/java/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverter.java
index f396a12..7a68fa1 100644
--- a/service/java/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverter.java
+++ b/service/java/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverter.java
@@ -28,6 +28,7 @@
 import android.util.ArraySet;
 import android.util.Log;
 
+import com.android.server.appsearch.external.localstorage.visibilitystore.CallerAccess;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityChecker;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityStore;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityUtil;
@@ -155,18 +156,14 @@
      * For each target schema, we will check visibility store is that accessible to the caller. And
      * remove this schemas if it is not allowed for caller to query.
      *
-     * @param callerPackageName The package name of caller
-     * @param callerUid The uid of the caller.
-     * @param callerHasSystemAccess Whether the caller has system access.
+     * @param callerAccess Visibility access info of the calling app
      * @param visibilityStore The {@link VisibilityStore} that store all visibility information.
      * @param visibilityChecker Optional visibility checker to check whether the caller could access
      *     target schemas. Pass {@code null} will reject access for all documents which doesn't
      *     belong to the calling package.
      */
     public void removeInaccessibleSchemaFilter(
-            @NonNull String callerPackageName,
-            int callerUid,
-            boolean callerHasSystemAccess,
+            @NonNull CallerAccess callerAccess,
             @Nullable VisibilityStore visibilityStore,
             @Nullable VisibilityChecker visibilityChecker) {
         Iterator<String> targetPrefixedSchemaFilterIterator =
@@ -176,9 +173,7 @@
             String packageName = getPackageName(targetPrefixedSchemaFilter);
 
             if (!VisibilityUtil.isSchemaSearchableByCaller(
-                    callerPackageName,
-                    callerUid,
-                    callerHasSystemAccess,
+                    callerAccess,
                     packageName,
                     targetPrefixedSchemaFilter,
                     visibilityStore,
diff --git a/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/CallerAccess.java b/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/CallerAccess.java
new file mode 100644
index 0000000..98f9468
--- /dev/null
+++ b/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/CallerAccess.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 2022 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.appsearch.external.localstorage.visibilitystore;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+
+import java.util.Objects;
+
+/**
+ * Contains attributes of an API caller relevant to its access via visibility store.
+ *
+ * @hide
+ */
+public class CallerAccess {
+    private final String mCallingPackageName;
+    private final int mCallingUid;
+    private final boolean mCallerHasSystemAccess;
+
+    /**
+     * Constructs a new {@link CallerAccess}.
+     *
+     * @param callingPackageName The name of the package which wants to access data.
+     * @param callingUid The uid of the package which wants to access data.
+     * @param callerHasSystemAccess Whether {@code callingPackageName} has access to schema types
+     *     marked visible to system via {@link
+     *     android.app.appsearch.SetSchemaRequest.Builder#setSchemaTypeDisplayedBySystem}.
+     */
+    public CallerAccess(
+            @NonNull String callingPackageName, int callingUid, boolean callerHasSystemAccess) {
+        mCallingPackageName = Objects.requireNonNull(callingPackageName);
+        mCallingUid = callingUid;
+        mCallerHasSystemAccess = callerHasSystemAccess;
+    }
+
+    /** Returns the name of the package which wants to access data. */
+    @NonNull
+    public String getCallingPackageName() {
+        return mCallingPackageName;
+    }
+
+    /** Returns the uid of the package which wants to access data. */
+    public int getCallingUid() {
+        return mCallingUid;
+    }
+
+    /**
+     * Returns whether {@code callingPackageName} has access to schema types marked visible to
+     * system via {@link
+     * android.app.appsearch.SetSchemaRequest.Builder#setSchemaTypeDisplayedBySystem}.
+     */
+    public boolean doesCallerHaveSystemAccess() {
+        return mCallerHasSystemAccess;
+    }
+
+    @Override
+    public boolean equals(@Nullable Object o) {
+        if (this == o) return true;
+        if (!(o instanceof CallerAccess)) return false;
+        CallerAccess that = (CallerAccess) o;
+        return mCallingUid == that.mCallingUid
+                && mCallerHasSystemAccess == that.mCallerHasSystemAccess
+                && mCallingPackageName.equals(that.mCallingPackageName);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mCallingPackageName, mCallingUid, mCallerHasSystemAccess);
+    }
+}
diff --git a/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityStore.java b/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityStore.java
index 22466c8..6bd8300 100644
--- a/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityStore.java
+++ b/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityStore.java
@@ -79,9 +79,10 @@
                 mAppSearchImpl.getSchema(
                         VISIBILITY_PACKAGE_NAME,
                         VISIBILITY_DATABASE_NAME,
-                        /*callerPackageName=*/ VISIBILITY_PACKAGE_NAME,
-                        /*callerUid=*/ Process.myUid(),
-                        /*callerHasSystemAccess=*/ false);
+                        new CallerAccess(
+                                /*callingPackageName=*/ VISIBILITY_PACKAGE_NAME,
+                                /*callingUid=*/ Process.myUid(),
+                                /*callerHasSystemAccess=*/ false));
         switch (getSchemaResponse.getVersion()) {
             case VisibilityDocument.SCHEMA_VERSION_DOC_PER_PACKAGE:
                 maybeMigrateToLatest(getSchemaResponse);
diff --git a/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityUtil.java b/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityUtil.java
index 8cc4dbd..4c625e3 100644
--- a/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityUtil.java
+++ b/service/java/com/android/server/appsearch/external/localstorage/visibilitystore/VisibilityUtil.java
@@ -35,10 +35,7 @@
      * <p>Correctly handles access to own data and the situation that visibilityStore and
      * visibilityChecker are not configured.
      *
-     * @param callerPackageName The package name of the app that wants to access the data.
-     * @param callerUid The uid of app that wants to access the data.
-     * @param callerHasSystemAccess Whether the app that wants to access the data has access to
-     *     schema types marked visible to the system.
+     * @param callerAccess Visibility access info of the calling app
      * @param targetPackageName The package name of the app that owns the data.
      * @param prefixedSchema The prefixed schema type the caller wants to access.
      * @param visibilityStore Store for visibility information. If not provided, only access to own
@@ -48,17 +45,16 @@
      * @return Whether access by the caller to this prefixed schema should be allowed.
      */
     public static boolean isSchemaSearchableByCaller(
-            @NonNull String callerPackageName,
-            int callerUid,
-            boolean callerHasSystemAccess,
+            @NonNull CallerAccess callerAccess,
             @NonNull String targetPackageName,
             @NonNull String prefixedSchema,
             @Nullable VisibilityStore visibilityStore,
             @Nullable VisibilityChecker visibilityChecker) {
-        Objects.requireNonNull(callerPackageName);
+        Objects.requireNonNull(callerAccess);
         Objects.requireNonNull(targetPackageName);
         Objects.requireNonNull(prefixedSchema);
-        if (callerPackageName.equals(targetPackageName)) {
+
+        if (callerAccess.getCallingPackageName().equals(targetPackageName)) {
             return true; // Everyone is always allowed to retrieve their own data.
         }
         if (visibilityStore == null || visibilityChecker == null) {
@@ -67,8 +63,8 @@
         return visibilityChecker.isSchemaSearchableByCaller(
                 targetPackageName,
                 prefixedSchema,
-                callerUid,
-                callerHasSystemAccess,
+                callerAccess.getCallingUid(),
+                callerAccess.doesCallerHaveSystemAccess(),
                 visibilityStore);
     }
 }
diff --git a/synced_jetpack_changeid.txt b/synced_jetpack_changeid.txt
index 7dfd853..69917e5 100644
--- a/synced_jetpack_changeid.txt
+++ b/synced_jetpack_changeid.txt
@@ -1 +1 @@
-5df0a880c467b3853603ebd196a4d3682974f1d8
+20277b994473ae37cd2c6773be629c40be59e31c
diff --git a/testing/servicestests/src/com/android/server/appsearch/external/localstorage/AppSearchImplTest.java b/testing/servicestests/src/com/android/server/appsearch/external/localstorage/AppSearchImplTest.java
index a4017e9..04ac957 100644
--- a/testing/servicestests/src/com/android/server/appsearch/external/localstorage/AppSearchImplTest.java
+++ b/testing/servicestests/src/com/android/server/appsearch/external/localstorage/AppSearchImplTest.java
@@ -38,6 +38,7 @@
 import android.app.appsearch.exceptions.AppSearchException;
 import android.app.appsearch.observer.DocumentChangeInfo;
 import android.app.appsearch.observer.ObserverSpec;
+import android.app.appsearch.observer.SchemaChangeInfo;
 import android.app.appsearch.testutil.TestObserverCallback;
 import android.content.Context;
 import android.os.Process;
@@ -50,6 +51,7 @@
 import com.android.server.appsearch.external.localstorage.stats.InitializeStats;
 import com.android.server.appsearch.external.localstorage.stats.OptimizeStats;
 import com.android.server.appsearch.external.localstorage.util.PrefixUtil;
+import com.android.server.appsearch.external.localstorage.visibilitystore.CallerAccess;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityChecker;
 import com.android.server.appsearch.icing.proto.DocumentProto;
 import com.android.server.appsearch.icing.proto.GetOptimizeInfoResultProto;
@@ -91,6 +93,17 @@
     private File mAppSearchDir;
 
     private final Context mContext = ApplicationProvider.getApplicationContext();
+
+    // Some constants for caller access
+    private final CallerAccess mInvalidCallerAccess =
+            new CallerAccess(
+                    mContext.getPackageName(),
+                    Process.INVALID_UID,
+                    /*callerHasSystemAccess=*/ false);
+    private final CallerAccess mSelfCallerAccess =
+            new CallerAccess(
+                    mContext.getPackageName(), Process.myUid(), /*callerHasSystemAccess=*/ false);
+
     private AppSearchImpl mAppSearchImpl;
 
     @Before
@@ -518,9 +531,7 @@
                 mAppSearchImpl.globalQuery(
                         /*queryExpression=*/ "",
                         new SearchSpec.Builder().addFilterSchemas("Type1").build(),
-                        mContext.getPackageName(),
-                        Process.INVALID_UID,
-                        /*callerHasSystemAccess=*/ false,
+                        mInvalidCallerAccess,
                         /*logger=*/ null);
         assertThat(results.getResults()).hasSize(1);
         assertThat(results.getResults().get(0).getGenericDocument()).isEqualTo(validDoc);
@@ -579,18 +590,14 @@
                                 .getSchema(
                                         /*packageName=*/ mContext.getPackageName(),
                                         /*databaseName=*/ "database1",
-                                        /*callerPackageName=*/ mContext.getPackageName(),
-                                        /*callerUid=*/ Process.myUid(),
-                                        /*callerHasSystemAccess=*/ false)
+                                        /*callerAccess=*/ mSelfCallerAccess)
                                 .getSchemas())
                 .isEmpty();
         results =
                 mAppSearchImpl.globalQuery(
                         /*queryExpression=*/ "",
                         new SearchSpec.Builder().addFilterSchemas("Type1").build(),
-                        mContext.getPackageName(),
-                        Process.INVALID_UID,
-                        /*callerHasSystemAccess=*/ false,
+                        mInvalidCallerAccess,
                         /*logger=*/ null);
         assertThat(results.getResults()).isEmpty();
 
@@ -613,9 +620,7 @@
                 mAppSearchImpl.globalQuery(
                         /*queryExpression=*/ "",
                         new SearchSpec.Builder().addFilterSchemas("Type1").build(),
-                        mContext.getPackageName(),
-                        Process.INVALID_UID,
-                        /*callerHasSystemAccess=*/ false,
+                        mInvalidCallerAccess,
                         /*logger=*/ null);
         assertThat(results.getResults()).hasSize(1);
         assertThat(results.getResults().get(0).getGenericDocument()).isEqualTo(validDoc);
@@ -748,16 +753,17 @@
     }
 
     @Test
-    public void testGlobalQueryEmptyDatabase() throws Exception {
+    public void testGlobalQuery_emptyPackage() throws Exception {
         SearchSpec searchSpec =
                 new SearchSpec.Builder().setTermMatch(TermMatchType.Code.PREFIX_VALUE).build();
         SearchResultPage searchResultPage =
                 mAppSearchImpl.globalQuery(
-                        "",
+                        /*queryExpression=*/ "",
                         searchSpec,
-                        /*callerPackageName=*/ "",
-                        Process.INVALID_UID,
-                        /*callerHasSystemAccess=*/ false,
+                        new CallerAccess(
+                                /*callingPackageName=*/ "",
+                                /*callingUid=*/ Process.INVALID_UID,
+                                /*callerHasSystemAccess=*/ false),
                         /*logger=*/ null);
         assertThat(searchResultPage.getResults()).isEmpty();
     }
@@ -894,9 +900,8 @@
                 mAppSearchImpl.globalQuery(
                         /*queryExpression=*/ "",
                         searchSpec,
-                        "package1",
-                        Process.myUid(),
-                        /*callerHasSystemAccess=*/ false,
+                        new CallerAccess(
+                                "package1", Process.myUid(), /*callerHasSystemAccess=*/ false),
                         /*logger=*/ null);
 
         // Document2 will come first because it was inserted last and default return order is
@@ -943,9 +948,8 @@
                 mAppSearchImpl.globalQuery(
                         /*queryExpression=*/ "",
                         searchSpec,
-                        "package1",
-                        Process.myUid(),
-                        /*callerHasSystemAccess=*/ false,
+                        new CallerAccess(
+                                "package1", Process.myUid(), /*callerHasSystemAccess=*/ false),
                         /*logger=*/ null);
 
         // Document2 will come first because it was inserted last and default return order is
@@ -1155,9 +1159,8 @@
                 mAppSearchImpl.globalQuery(
                         /*queryExpression=*/ "",
                         searchSpec,
-                        "package1",
-                        Process.myUid(),
-                        /*callerHasSystemAccess=*/ false,
+                        new CallerAccess(
+                                "package1", Process.myUid(), /*callerHasSystemAccess=*/ false),
                         /*logger=*/ null);
 
         // Document2 will come first because it was inserted last and default return order is
@@ -1215,9 +1218,8 @@
                 mAppSearchImpl.globalQuery(
                         /*queryExpression=*/ "",
                         searchSpec,
-                        "package1",
-                        Process.myUid(),
-                        /*callerHasSystemAccess=*/ false,
+                        new CallerAccess(
+                                "package1", Process.myUid(), /*callerHasSystemAccess=*/ false),
                         /*logger=*/ null);
 
         // Document2 will come first because it was inserted last and default return order is
@@ -2085,9 +2087,7 @@
                         mAppSearchImpl.getSchema(
                                 /*packageName=*/ "package",
                                 /*databaseName=*/ "database",
-                                /*callerPackageName=*/ mContext.getPackageName(),
-                                /*callerUid=*/ Process.myUid(),
-                                /*callerHasSystemAccess=*/ false));
+                                /*callerAccess=*/ mSelfCallerAccess));
 
         assertThrows(
                 IllegalStateException.class,
@@ -2120,9 +2120,7 @@
                         mAppSearchImpl.globalQuery(
                                 "query",
                                 new SearchSpec.Builder().build(),
-                                "package",
-                                Process.INVALID_UID,
-                                /*callerHasSystemAccess=*/ false,
+                                mInvalidCallerAccess,
                                 /*logger=*/ null));
 
         assertThrows(
@@ -3161,17 +3159,13 @@
         // Register an observer twice, on different packages.
         TestObserverCallback observer = new TestObserverCallback();
         mAppSearchImpl.addObserver(
-                /*listeningPackageName=*/ mContext.getPackageName(),
-                /*listeningUid=*/ Process.myUid(),
-                /*listeningPackageHasSystemAccess=*/ false,
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
                 /*targetPackageName=*/ mContext.getPackageName(),
                 new ObserverSpec.Builder().build(),
                 MoreExecutors.directExecutor(),
                 observer);
         mAppSearchImpl.addObserver(
-                /*listeningPackageName=*/ mContext.getPackageName(),
-                /*listeningUid=*/ Process.myUid(),
-                /*listeningPackageHasSystemAccess=*/ false,
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
                 /*targetPackageName=*/ fakePackage,
                 new ObserverSpec.Builder().build(),
                 MoreExecutors.directExecutor(),
@@ -3254,9 +3248,7 @@
                                         "namespace1",
                                         "id1",
                                         /*typePropertyPaths=*/ Collections.emptyMap(),
-                                        /*callerPackageName=*/ mContext.getPackageName(),
-                                        /*callerUid=*/ Process.myUid(),
-                                        /*callerHasSystemAccess=*/ false));
+                                        /*callerAccess=*/ mSelfCallerAccess));
         assertThat(e.getResultCode()).isEqualTo(AppSearchResult.RESULT_NOT_FOUND);
         assertThat(e.getMessage()).isEqualTo("Document (namespace1, id1) not found.");
     }
@@ -3302,9 +3294,7 @@
                         "namespace1",
                         "id1",
                         /*typePropertyPaths=*/ Collections.emptyMap(),
-                        /*callerPackageName=*/ mContext.getPackageName(),
-                        /*callerUid=*/ Process.myUid(),
-                        /*callerHasSystemAccess=*/ false);
+                        /*callerAccess=*/ mSelfCallerAccess);
         assertThat(getResult).isEqualTo(document);
     }
 
@@ -3352,9 +3342,7 @@
                                         "namespace1",
                                         "id2",
                                         /*typePropertyPaths=*/ Collections.emptyMap(),
-                                        /*callerPackageName=*/ mContext.getPackageName(),
-                                        /*callerUid=*/ Process.myUid(),
-                                        /*callerHasSystemAccess=*/ false));
+                                        /*callerAccess=*/ mSelfCallerAccess));
         assertThat(e.getResultCode()).isEqualTo(AppSearchResult.RESULT_NOT_FOUND);
         assertThat(e.getMessage()).isEqualTo("Document (namespace1, id2) not found.");
     }
@@ -3402,9 +3390,7 @@
                                         "namespace1",
                                         "id1",
                                         /*typePropertyPaths=*/ Collections.emptyMap(),
-                                        /*callerPackageName=*/ mContext.getPackageName(),
-                                        /*callerUid=*/ Process.myUid(),
-                                        /*callerHasSystemAccess=*/ false));
+                                        /*callerAccess=*/ mSelfCallerAccess));
 
         mAppSearchImpl.remove(
                 "package", "database", "namespace1", "id1", /*removeStatsBuilder=*/ null);
@@ -3419,9 +3405,10 @@
                                         "namespace1",
                                         "id1",
                                         /*typePropertyPaths=*/ Collections.emptyMap(),
-                                        /*callerPackageName=*/ "package",
-                                        /*callerUid=*/ Process.myUid(),
-                                        /*callerHasSystemAccess=*/ true));
+                                        new CallerAccess(
+                                                "package",
+                                                Process.myUid(),
+                                                /*callerHasSystemAccess=*/ true)));
 
         assertThat(noDocException.getResultCode()).isEqualTo(unauthorizedException.getResultCode());
         assertThat(noDocException.getMessage()).isEqualTo(unauthorizedException.getMessage());
@@ -3674,9 +3661,10 @@
                 mAppSearchImpl.getSchema(
                         "package",
                         "database",
-                        /*callerPackageName=*/ "com.android.appsearch.fake.package",
-                        /*callerUid=*/ 1,
-                        /*callerHasSystemAccess=*/ false);
+                        new CallerAccess(
+                                "com.android.appsearch.fake.package",
+                                /*callerUid=*/ 1,
+                                /*callerHasSystemAccess=*/ false));
         assertThat(getResponse.getSchemas()).containsExactlyElementsIn(schemas);
         assertThat(getResponse.getSchemaTypesNotDisplayedBySystem()).containsExactly("Type");
     }
@@ -3698,9 +3686,8 @@
                 mAppSearchImpl.getSchema(
                         "com.android.appsearch.fake.package",
                         "database",
-                        /*callerPackageName=*/ "package",
-                        /*callerUid=*/ 1,
-                        /*callerHasSystemAccess=*/ false);
+                        new CallerAccess(
+                                "package", /*callerUid=*/ 1, /*callerHasSystemAccess=*/ false));
         assertThat(getResponse.getSchemas()).isEmpty();
         assertThat(getResponse.getSchemaTypesNotDisplayedBySystem()).isEmpty();
     }
@@ -3721,9 +3708,10 @@
                 mAppSearchImpl.getSchema(
                         "package",
                         "database",
-                        /*callerPackageName=*/ "com.android.fake.package",
-                        /*callerUid=*/ 1,
-                        /*callerHasSystemAccess=*/ false);
+                        new CallerAccess(
+                                "com.android.appsearch.fake.package",
+                                /*callerUid=*/ 1,
+                                /*callerHasSystemAccess=*/ false));
         assertThat(getResponse.getSchemas()).isEmpty();
         assertThat(getResponse.getSchemaTypesNotDisplayedBySystem()).isEmpty();
         assertThat(getResponse.getVersion()).isEqualTo(0);
@@ -3734,9 +3722,8 @@
                 mAppSearchImpl.getSchema(
                         "package",
                         "database",
-                        /*callerPackageName=*/ "package",
-                        /*callerUid=*/ 1,
-                        /*callerHasSystemAccess=*/ false);
+                        new CallerAccess(
+                                "package", /*callerUid=*/ 1, /*callerHasSystemAccess=*/ false));
         assertThat(getResponse.getSchemas()).containsExactlyElementsIn(schemas);
     }
 
@@ -3780,9 +3767,10 @@
                 mAppSearchImpl.getSchema(
                         "package",
                         "database",
-                        /*callerPackageName=*/ "com.android.appsearch.fake.package",
-                        /*callerUid=*/ 1,
-                        /*callerHasSystemAccess=*/ false);
+                        new CallerAccess(
+                                "com.android.appsearch.fake.package",
+                                /*callerUid=*/ 1,
+                                /*callerHasSystemAccess=*/ false));
         assertThat(getResponse.getSchemas()).containsExactly(schemas.get(0));
         assertThat(getResponse.getSchemaTypesNotDisplayedBySystem()).containsExactly("VisibleType");
         assertThat(getResponse.getVersion()).isEqualTo(1);
@@ -3802,9 +3790,7 @@
         // Register an observer
         TestObserverCallback observer = new TestObserverCallback();
         mAppSearchImpl.addObserver(
-                /*listeningPackageName=*/ mContext.getPackageName(),
-                Process.myUid(),
-                /*listeningPackageHasSystemAccess=*/ false,
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
                 /*targetPackageName=*/ mContext.getPackageName(),
                 new ObserverSpec.Builder().build(),
                 MoreExecutors.directExecutor(),
@@ -3861,9 +3847,7 @@
         // Register an observer
         TestObserverCallback observer = new TestObserverCallback();
         mAppSearchImpl.addObserver(
-                /*listeningPackageName=*/ mContext.getPackageName(),
-                Process.myUid(),
-                /*listeningPackageHasSystemAccess=*/ false,
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
                 /*targetPackageName=*/ mContext.getPackageName(),
                 new ObserverSpec.Builder().build(),
                 MoreExecutors.directExecutor(),
@@ -3907,9 +3891,10 @@
         // Register an observer from a simulated different package
         TestObserverCallback observer = new TestObserverCallback();
         mAppSearchImpl.addObserver(
-                /*listeningPackageName=*/ "com.fake.Listening.package",
-                Process.myUid(),
-                /*listeningPackageHasSystemAccess=*/ false,
+                new CallerAccess(
+                        "com.fake.Listening.package",
+                        Process.myUid(),
+                        /*callerHasSystemAccess=*/ false),
                 /*targetPackageName=*/ mContext.getPackageName(),
                 new ObserverSpec.Builder().build(),
                 MoreExecutors.directExecutor(),
@@ -3962,9 +3947,8 @@
         // Register an observer
         TestObserverCallback observer = new TestObserverCallback();
         mAppSearchImpl.addObserver(
-                fakeListeningPackage,
-                fakeListeningUid,
-                /*listeningPackageHasSystemAccess=*/ false,
+                new CallerAccess(
+                        fakeListeningPackage, fakeListeningUid, /*callerHasSystemAccess=*/ false),
                 /*targetPackageName=*/ mContext.getPackageName(),
                 new ObserverSpec.Builder().build(),
                 MoreExecutors.directExecutor(),
@@ -4024,9 +4008,8 @@
         // Register an observer
         TestObserverCallback observer = new TestObserverCallback();
         mAppSearchImpl.addObserver(
-                fakeListeningPackage,
-                fakeListeningUid,
-                /*listeningPackageHasSystemAccess=*/ false,
+                new CallerAccess(
+                        fakeListeningPackage, fakeListeningUid, /*callerHasSystemAccess=*/ false),
                 /*targetPackageName=*/ mContext.getPackageName(),
                 new ObserverSpec.Builder().build(),
                 MoreExecutors.directExecutor(),
@@ -4048,4 +4031,734 @@
         assertThat(observer.getSchemaChanges()).isEmpty();
         assertThat(observer.getDocumentChanges()).isEmpty();
     }
+
+    @Test
+    public void testAddObserver_schemaChange_added() throws Exception {
+        // Register an observer
+        TestObserverCallback observer = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observer);
+
+        // Add a schema type
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(new AppSearchSchema.Builder("Type1").build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+
+        // Dispatch notifications
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type1")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+
+        // Add two more schema types without touching the existing one
+        observer.clear();
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2").build(),
+                        new AppSearchSchema.Builder("Type3").build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+
+        // Dispatch notifications
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(),
+                                "database1",
+                                ImmutableSet.of("Type2", "Type3")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+    }
+
+    @Test
+    public void testAddObserver_schemaChange_removed() throws Exception {
+        // Add a schema type
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2").build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Register an observer
+        TestObserverCallback observer = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observer);
+
+        // Remove Type2
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(new AppSearchSchema.Builder("Type1").build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+    }
+
+    @Test
+    public void testAddObserver_schemaChange_contents() throws Exception {
+        // Add a schema
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_REQUIRED)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Register an observer
+        TestObserverCallback observer = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observer);
+
+        // Update the schema, but don't make any actual changes
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_REQUIRED)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 1,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+
+        // Now update the schema again, but this time actually make a change (cardinality of the
+        // property)
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_OPTIONAL)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 2,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+    }
+
+    @Test
+    public void testAddObserver_schemaChange_contents_skipBySpec() throws Exception {
+        // Add a schema
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_REQUIRED)
+                                                .build())
+                                .build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_REQUIRED)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Register an observer that only listens for Type2
+        TestObserverCallback observer = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                /*listeningPackageAccess=*/ mSelfCallerAccess,
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().addFilterSchemas("Type2").build(),
+                MoreExecutors.directExecutor(),
+                observer);
+
+        // Update both types of the schema (changed cardinalities)
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_OPTIONAL)
+                                                .build())
+                                .build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_OPTIONAL)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+    }
+
+    @Test
+    public void testAddObserver_schemaChange_visibilityOnly() throws Exception {
+        final String fakeListeningPackage = "com.fake.listening.package";
+        final int fakeListeningUid = 42;
+
+        // Make a fake visibility checker that actually looks at visibility store
+        final VisibilityChecker visibilityChecker =
+                (packageName,
+                        prefixedSchema,
+                        callerUid,
+                        callerHasSystemAccess,
+                        visibilityStore) -> {
+                    if (callerUid != fakeListeningUid) {
+                        return false;
+                    }
+                    Set<String> allowedPackages =
+                            new ArraySet<>(
+                                    visibilityStore
+                                            .getVisibility(prefixedSchema)
+                                            .getPackageNames());
+                    return allowedPackages.contains(fakeListeningPackage);
+                };
+        mAppSearchImpl.close();
+        mAppSearchImpl =
+                AppSearchImpl.create(
+                        mAppSearchDir,
+                        new UnlimitedLimitConfig(),
+                        /*initStatsBuilder=*/ null,
+                        ALWAYS_OPTIMIZE,
+                        visibilityChecker);
+
+        // Register an observer
+        TestObserverCallback observer = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                new CallerAccess(
+                        fakeListeningPackage, fakeListeningUid, /*callerHasSystemAccess=*/ false),
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observer);
+
+        // Add a schema where both types are visible to the fake package.
+        List<AppSearchSchema> schemas =
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2").build());
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                schemas,
+                /*visibilityDocuments=*/ ImmutableList.of(
+                        new VisibilityDocument.Builder("Type1")
+                                .addVisibleToPackage(
+                                        new PackageIdentifier(fakeListeningPackage, new byte[0]))
+                                .build(),
+                        new VisibilityDocument.Builder("Type2")
+                                .addVisibleToPackage(
+                                        new PackageIdentifier(fakeListeningPackage, new byte[0]))
+                                .build()),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Notifications of addition should now be dispatched
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(),
+                                "database1",
+                                ImmutableSet.of("Type1", "Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        observer.clear();
+
+        // Update schema, keeping the types identical but denying visibility to type2
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                schemas,
+                /*visibilityDocuments=*/ ImmutableList.of(
+                        new VisibilityDocument.Builder("Type1")
+                                .addVisibleToPackage(
+                                        new PackageIdentifier(fakeListeningPackage, new byte[0]))
+                                .build(),
+                        new VisibilityDocument.Builder("Type2").build()),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications. This should look like a deletion of Type2.
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        observer.clear();
+
+        // Now update Type2 and make sure no further notification is received.
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_OPTIONAL)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ ImmutableList.of(
+                        new VisibilityDocument.Builder("Type1")
+                                .addVisibleToPackage(
+                                        new PackageIdentifier(fakeListeningPackage, new byte[0]))
+                                .build(),
+                        new VisibilityDocument.Builder("Type2").build()),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+
+        // Grant visibility to Type2 again and make sure it appears
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_OPTIONAL)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ ImmutableList.of(
+                        new VisibilityDocument.Builder("Type1")
+                                .addVisibleToPackage(
+                                        new PackageIdentifier(fakeListeningPackage, new byte[0]))
+                                .build(),
+                        new VisibilityDocument.Builder("Type2")
+                                .addVisibleToPackage(
+                                        new PackageIdentifier(fakeListeningPackage, new byte[0]))
+                                .build()),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications. This should look like a creation of Type2.
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+    }
+
+    @Test
+    public void testAddObserver_schemaChange_visibilityAndContents() throws Exception {
+        final String fakeListeningPackage = "com.fake.listening.package";
+        final int fakeListeningUid = 42;
+
+        // Make a visibility checker that allows fakeListeningPackage access only to Type2.
+        final VisibilityChecker visibilityChecker =
+                (packageName, prefixedSchema, callerUid, callerHasSystemAccess, visibilityStore) ->
+                        callerUid == fakeListeningUid && prefixedSchema.endsWith("Type2");
+        mAppSearchImpl.close();
+        mAppSearchImpl =
+                AppSearchImpl.create(
+                        mAppSearchDir,
+                        new UnlimitedLimitConfig(),
+                        /*initStatsBuilder=*/ null,
+                        ALWAYS_OPTIMIZE,
+                        visibilityChecker);
+
+        // Add a schema.
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_REQUIRED)
+                                                .build())
+                                .build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_REQUIRED)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Register an observer
+        TestObserverCallback observer = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                new CallerAccess(
+                        fakeListeningPackage, fakeListeningUid, /*callerHasSystemAccess=*/ false),
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observer);
+
+        // Update both types of the schema (changed cardinalities)
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_OPTIONAL)
+                                                .build())
+                                .build(),
+                        new AppSearchSchema.Builder("Type2")
+                                .addProperty(
+                                        new AppSearchSchema.BooleanPropertyConfig.Builder(
+                                                        "booleanProp")
+                                                .setCardinality(
+                                                        AppSearchSchema.PropertyConfig
+                                                                .CARDINALITY_OPTIONAL)
+                                                .build())
+                                .build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+    }
+
+    @Test
+    public void testAddObserver_schemaChange_partialVisibility_removed() throws Exception {
+        final String fakeListeningPackage = "com.fake.listening.package";
+        final int fakeListeningUid = 42;
+
+        // Make a visibility checker that allows fakeListeningPackage access only to Type2.
+        final VisibilityChecker visibilityChecker =
+                (packageName, prefixedSchema, callerUid, callerHasSystemAccess, visibilityStore) ->
+                        callerUid == fakeListeningUid && prefixedSchema.endsWith("Type2");
+        mAppSearchImpl.close();
+        mAppSearchImpl =
+                AppSearchImpl.create(
+                        mAppSearchDir,
+                        new UnlimitedLimitConfig(),
+                        /*initStatsBuilder=*/ null,
+                        ALWAYS_OPTIMIZE,
+                        visibilityChecker);
+
+        // Add a schema.
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2").build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Register an observer
+        TestObserverCallback observer = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                new CallerAccess(
+                        fakeListeningPackage, fakeListeningUid, /*callerHasSystemAccess=*/ false),
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observer);
+
+        // Remove Type1
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(new AppSearchSchema.Builder("Type2").build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications. Nothing should appear since Type1 is not visible to us.
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+
+        // Now remove Type2. This should cause a notification.
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+        assertThat(observer.getSchemaChanges()).isEmpty();
+        assertThat(observer.getDocumentChanges()).isEmpty();
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+        assertThat(observer.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type2")));
+        assertThat(observer.getDocumentChanges()).isEmpty();
+    }
+
+    @Test
+    public void testAddObserver_schemaChange_multipleObservers() throws Exception {
+        // Create two fake packages. One can access Type1, one can access Type2, they both can
+        // access Type3, and no one can access Type4.
+        final String fakePackage1 = "com.fake.listening.package1";
+        final int fakePackage1Uid = 42;
+
+        final String fakePackage2 = "com.fake.listening.package2";
+        final int fakePackage2Uid = 43;
+
+        final VisibilityChecker visibilityChecker =
+                (packageName,
+                        prefixedSchema,
+                        callerUid,
+                        callerHasSystemAccess,
+                        visibilityStore) -> {
+                    if (prefixedSchema.endsWith("Type1")) {
+                        return callerUid == fakePackage1Uid;
+                    } else if (prefixedSchema.endsWith("Type2")) {
+                        return callerUid == fakePackage2Uid;
+                    } else if (prefixedSchema.endsWith("Type3")) {
+                        return false;
+                    } else if (prefixedSchema.endsWith("Type4")) {
+                        return true;
+                    } else {
+                        throw new IllegalArgumentException(prefixedSchema);
+                    }
+                };
+        mAppSearchImpl.close();
+        mAppSearchImpl =
+                AppSearchImpl.create(
+                        mAppSearchDir,
+                        new UnlimitedLimitConfig(),
+                        /*initStatsBuilder=*/ null,
+                        ALWAYS_OPTIMIZE,
+                        visibilityChecker);
+
+        // Add a schema.
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(
+                        new AppSearchSchema.Builder("Type1").build(),
+                        new AppSearchSchema.Builder("Type2").build(),
+                        new AppSearchSchema.Builder("Type3").build(),
+                        new AppSearchSchema.Builder("Type4").build()),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Register three observers: one in each package, and another in package1 with a filter.
+        TestObserverCallback observerPkg1NoFilter = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                new CallerAccess(fakePackage1, fakePackage1Uid, /*callerHasSystemAccess=*/ false),
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observerPkg1NoFilter);
+
+        TestObserverCallback observerPkg2NoFilter = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                new CallerAccess(fakePackage2, fakePackage2Uid, /*callerHasSystemAccess=*/ false),
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().build(),
+                MoreExecutors.directExecutor(),
+                observerPkg2NoFilter);
+
+        TestObserverCallback observerPkg1FilterType4 = new TestObserverCallback();
+        mAppSearchImpl.addObserver(
+                new CallerAccess(fakePackage1, fakePackage1Uid, /*callerHasSystemAccess=*/ false),
+                /*targetPackageName=*/ mContext.getPackageName(),
+                new ObserverSpec.Builder().addFilterSchemas("Type4").build(),
+                MoreExecutors.directExecutor(),
+                observerPkg1FilterType4);
+
+        // Remove everything
+        mAppSearchImpl.setSchema(
+                mContext.getPackageName(),
+                "database1",
+                ImmutableList.of(),
+                /*visibilityDocuments=*/ Collections.emptyList(),
+                /*forceOverride=*/ false,
+                /*version=*/ 0,
+                /*setSchemaStatsBuilder=*/ null);
+
+        // Dispatch notifications.
+        mAppSearchImpl.dispatchAndClearChangeNotifications();
+
+        // observerPkg1NoFilter should see Type1 and Type4 vanish.
+        // observerPkg2NoFilter should see Type2 and Type4 vanish.
+        // observerPkg2WithFilter should see Type4 vanish.
+        assertThat(observerPkg1NoFilter.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(),
+                                "database1",
+                                ImmutableSet.of("Type1", "Type4")));
+        assertThat(observerPkg1NoFilter.getDocumentChanges()).isEmpty();
+
+        assertThat(observerPkg2NoFilter.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(),
+                                "database1",
+                                ImmutableSet.of("Type2", "Type4")));
+        assertThat(observerPkg2NoFilter.getDocumentChanges()).isEmpty();
+
+        assertThat(observerPkg1FilterType4.getSchemaChanges())
+                .containsExactly(
+                        new SchemaChangeInfo(
+                                mContext.getPackageName(), "database1", ImmutableSet.of("Type4")));
+        assertThat(observerPkg1FilterType4.getDocumentChanges()).isEmpty();
+    }
 }
diff --git a/testing/servicestests/src/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverterTest.java b/testing/servicestests/src/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverterTest.java
index b9ce1ac..1bd73ac 100644
--- a/testing/servicestests/src/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverterTest.java
+++ b/testing/servicestests/src/com/android/server/appsearch/external/localstorage/converter/SearchSpecToProtoConverterTest.java
@@ -31,6 +31,7 @@
 import com.android.server.appsearch.external.localstorage.OptimizeStrategy;
 import com.android.server.appsearch.external.localstorage.UnlimitedLimitConfig;
 import com.android.server.appsearch.external.localstorage.util.PrefixUtil;
+import com.android.server.appsearch.external.localstorage.visibilitystore.CallerAccess;
 import com.android.server.appsearch.external.localstorage.visibilitystore.VisibilityStore;
 import com.android.server.appsearch.icing.proto.ResultSpecProto;
 import com.android.server.appsearch.icing.proto.SchemaTypeConfigProto;
@@ -497,10 +498,9 @@
                                         "package$database/schema3", schemaTypeConfigProto)));
 
         converter.removeInaccessibleSchemaFilter(
-                /*callerPackageName=*/ "otherPackageName",
-                /*callerUid=*/ -1,
-                /*callerHasSystemAccess=*/ true,
-                /*visibilityStore=*/ visibilityStore,
+                new CallerAccess(
+                        "otherPackageName", /*callingUid=*/ -1, /*callerHasSystemAccess=*/ true),
+                visibilityStore,
                 AppSearchTestUtils.createMockVisibilityChecker(
                         /*visiblePrefixedSchemas=*/ ImmutableSet.of(
                                 prefix + "schema1", prefix + "schema3")));
@@ -552,9 +552,8 @@
 
         // remove all target schema filter, and the query becomes nothing to search.
         nonEmptyConverter.removeInaccessibleSchemaFilter(
-                /*callerPackageName=*/ "otherPackageName",
-                /*callerUid=*/ -1,
-                /*callerHasSystemAccess=*/ true,
+                new CallerAccess(
+                        "otherPackageName", /*callingUid=*/ -1, /*callerHasSystemAccess=*/ true),
                 /*visibilityStore=*/ null,
                 /*visibilityChecker=*/ null);
         assertThat(nonEmptyConverter.isNothingToSearch()).isTrue();