blob: bbd268030351ff725887ec28d4321969a1acb420 [file] [log] [blame]
/*
* Copyright 2021 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;
import android.annotation.NonNull;
import android.annotation.Nullable;
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;
import java.util.Set;
import java.util.concurrent.Executor;
/**
* Manages {@link AppSearchObserverCallback} instances and queues notifications to them for later
* dispatch.
*
* <p>This class is thread-safe.
*
* @hide
*/
public class ObserverManager {
private static final String TAG = "AppSearchObserverManage";
/** The combination of fields by which {@link DocumentChangeInfo} is grouped. */
private static final class DocumentChangeGroupKey {
final String mPackageName;
final String mDatabaseName;
final String mNamespace;
final String mSchemaName;
DocumentChangeGroupKey(
@NonNull String packageName,
@NonNull String databaseName,
@NonNull String namespace,
@NonNull String schemaName) {
mPackageName = Objects.requireNonNull(packageName);
mDatabaseName = Objects.requireNonNull(databaseName);
mNamespace = Objects.requireNonNull(namespace);
mSchemaName = Objects.requireNonNull(schemaName);
}
@Override
public boolean equals(@Nullable Object o) {
if (this == o) return true;
if (!(o instanceof DocumentChangeGroupKey)) return false;
DocumentChangeGroupKey that = (DocumentChangeGroupKey) o;
return mPackageName.equals(that.mPackageName)
&& mDatabaseName.equals(that.mDatabaseName)
&& mNamespace.equals(that.mNamespace)
&& mSchemaName.equals(that.mSchemaName);
}
@Override
public int hashCode() {
return Objects.hash(mPackageName, mDatabaseName, mNamespace, mSchemaName);
}
}
private static final class ObserverInfo {
/** The package which registered the observer. */
final CallerAccess mListeningPackageAccess;
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 CallerAccess listeningPackageAccess,
@NonNull ObserverSpec observerSpec,
@NonNull Executor executor,
@NonNull AppSearchObserverCallback observer) {
mListeningPackageAccess = Objects.requireNonNull(listeningPackageAccess);
mObserverSpec = Objects.requireNonNull(observerSpec);
mExecutor = Objects.requireNonNull(executor);
mObserver = Objects.requireNonNull(observer);
}
}
private final Object mLock = new Object();
/** Maps target packages to ObserverInfos watching something in that package. */
@GuardedBy("mLock")
private final Map<String, List<ObserverInfo>> mObserversLocked = new ArrayMap<>();
private volatile boolean mHasNotifications = false;
/**
* Adds an {@link AppSearchObserverCallback} to monitor changes within the databases owned by
* {@code targetPackageName} if they match the given {@link
* android.app.appsearch.observer.ObserverSpec}.
*
* <p>If the data owned by {@code targetPackageName} is not visible to you, the registration
* call will succeed but no notifications will be dispatched. Notifications could start flowing
* later if {@code targetPackageName} changes its schema visibility settings.
*
* <p>If no package matching {@code targetPackageName} exists on the system, the registration
* call will succeed but no notifications will be dispatched. Notifications could start flowing
* later if {@code targetPackageName} is installed and starts indexing data.
*
* <p>Note that this method does not take the standard read/write lock that guards I/O, so it
* will not queue behind I/O. Therefore it is safe to call from any thread including UI or
* binder threads.
*
* @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.
* @param executor The executor on which to trigger the observer callback to deliver
* notifications.
* @param observer The callback to trigger on notifications.
*/
public void addObserver(
@NonNull CallerAccess listeningPackageAccess,
@NonNull String targetPackageName,
@NonNull ObserverSpec spec,
@NonNull Executor executor,
@NonNull AppSearchObserverCallback observer) {
synchronized (mLock) {
List<ObserverInfo> infos = mObserversLocked.get(targetPackageName);
if (infos == null) {
infos = new ArrayList<>();
mObserversLocked.put(targetPackageName, infos);
}
infos.add(new ObserverInfo(listeningPackageAccess, spec, executor, observer));
}
}
/**
* Removes all observers that match via {@link AppSearchObserverCallback#equals} to the given
* observer from watching the targetPackageName.
*
* <p>Pending notifications queued for this observer, if any, are discarded.
*/
public void removeObserver(
@NonNull String targetPackageName, @NonNull AppSearchObserverCallback observer) {
synchronized (mLock) {
List<ObserverInfo> infos = mObserversLocked.get(targetPackageName);
if (infos == null) {
return;
}
for (int i = 0; i < infos.size(); i++) {
if (infos.get(i).mObserver.equals(observer)) {
infos.remove(i);
i--;
}
}
}
}
/**
* Should be called when a change occurs to a document.
*
* <p>The notification will be queued in memory for later dispatch. You must call {@link
* #dispatchAndClearPendingNotifications} to dispatch all such pending notifications.
*
* @param visibilityStore Store for visibility information. If not provided, only access to own
* data will be allowed.
* @param visibilityChecker Checker for visibility access. If not provided, only access to own
* data will be allowed.
*/
public void onDocumentChange(
@NonNull String packageName,
@NonNull String databaseName,
@NonNull String namespace,
@NonNull String schemaType,
@NonNull String documentId,
@Nullable VisibilityStore visibilityStore,
@Nullable VisibilityChecker visibilityChecker) {
synchronized (mLock) {
List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(packageName);
if (allObserverInfosForPackage == null || allObserverInfosForPackage.isEmpty()) {
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
}
if (!VisibilityUtil.isSchemaSearchableByCaller(
/*callerAccess=*/ observerInfo.mListeningPackageAccess,
/*targetPackageName=*/ packageName,
/*prefixedSchema=*/ prefixedSchema,
visibilityStore,
visibilityChecker)) {
continue; // Observer can't have this notification.
}
if (key == null) {
key =
new DocumentChangeGroupKey(
packageName, databaseName, namespace, schemaType);
}
Set<String> changedDocumentIds = observerInfo.mDocumentChanges.get(key);
if (changedDocumentIds == null) {
changedDocumentIds = new ArraySet<>();
observerInfo.mDocumentChanges.put(key, changedDocumentIds);
}
changedDocumentIds.add(documentId);
}
mHasNotifications = true;
}
}
/**
* 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) {
return mObserversLocked.containsKey(packageName);
}
}
/**
* Returns whether there are any observers registered to watch the given package and unprefixed
* schema type.
*/
public boolean isSchemaTypeObserved(@NonNull String packageName, @NonNull String schemaType) {
synchronized (mLock) {
List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(packageName);
if (allObserverInfosForPackage == null) {
return false;
}
for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
if (matchesSpec(schemaType, observerInfo.mObserverSpec)) {
return true;
}
}
return false;
}
}
/**
* 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;
}
/** Dispatches notifications on their corresponding executors. */
public void dispatchAndClearPendingNotifications() {
if (!mHasNotifications) {
return;
}
synchronized (mLock) {
if (mObserversLocked.isEmpty() || !mHasNotifications) {
return;
}
for (List<ObserverInfo> observerInfos : mObserversLocked.values()) {
for (int i = 0; i < observerInfos.size(); i++) {
dispatchAndClearPendingNotificationsLocked(observerInfos.get(i));
}
}
mHasNotifications = false;
}
}
/** Dispatches pending notifications for the given observerInfo and clears the pending list. */
@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 (schemaChanges.isEmpty() && documentChanges.isEmpty()) {
return;
}
if (!schemaChanges.isEmpty()) {
observerInfo.mSchemaChanges = new ArrayMap<>();
}
if (!documentChanges.isEmpty()) {
observerInfo.mDocumentChanges = new ArrayMap<>();
}
// Dispatch the pending changes
observerInfo.mExecutor.execute(
() -> {
// 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 {
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);
}
}
}
});
}
/**
* Checks whether a change in the given {@code databaseName}, {@code namespace} and {@code
* schemaType} passes all the filters defined in the given {@code observerSpec}.
*
* <p>Note that this method does not check packageName; you must only use it to check
* observerSpecs which you know are observing the same package as the change.
*/
private static boolean matchesSpec(
@NonNull String schemaType, @NonNull ObserverSpec observerSpec) {
Set<String> schemaFilters = observerSpec.getFilterSchemas();
return schemaFilters.isEmpty() || schemaFilters.contains(schemaType);
}
}