1 /*
2  * Copyright 2021 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.appsearch.localstorage;
18 
19 import android.util.Log;
20 
21 import androidx.annotation.GuardedBy;
22 import androidx.annotation.RestrictTo;
23 import androidx.appsearch.localstorage.util.PrefixUtil;
24 import androidx.appsearch.localstorage.visibilitystore.CallerAccess;
25 import androidx.appsearch.localstorage.visibilitystore.VisibilityChecker;
26 import androidx.appsearch.localstorage.visibilitystore.VisibilityStore;
27 import androidx.appsearch.localstorage.visibilitystore.VisibilityUtil;
28 import androidx.appsearch.observer.DocumentChangeInfo;
29 import androidx.appsearch.observer.ObserverCallback;
30 import androidx.appsearch.observer.ObserverSpec;
31 import androidx.appsearch.observer.SchemaChangeInfo;
32 import androidx.appsearch.util.ExceptionUtil;
33 import androidx.collection.ArrayMap;
34 import androidx.collection.ArraySet;
35 import androidx.core.util.ObjectsCompat;
36 import androidx.core.util.Preconditions;
37 
38 import org.jspecify.annotations.NonNull;
39 import org.jspecify.annotations.Nullable;
40 
41 import java.util.ArrayList;
42 import java.util.Collections;
43 import java.util.List;
44 import java.util.Map;
45 import java.util.Set;
46 import java.util.concurrent.Executor;
47 
48 /**
49  * Manages {@link ObserverCallback} instances and queues notifications to them for later
50  * dispatch.
51  *
52  * <p>This class is thread-safe.
53  *
54  * @exportToFramework:hide
55  */
56 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
57 public class ObserverManager {
58     private static final String TAG = "AppSearchObserverManage";
59 
60     /** The combination of fields by which {@link DocumentChangeInfo} is grouped. */
61     private static final class DocumentChangeGroupKey {
62         final String mPackageName;
63         final String mDatabaseName;
64         final String mNamespace;
65         final String mSchemaName;
66 
DocumentChangeGroupKey( @onNull String packageName, @NonNull String databaseName, @NonNull String namespace, @NonNull String schemaName)67         DocumentChangeGroupKey(
68                 @NonNull String packageName,
69                 @NonNull String databaseName,
70                 @NonNull String namespace,
71                 @NonNull String schemaName) {
72             mPackageName = Preconditions.checkNotNull(packageName);
73             mDatabaseName = Preconditions.checkNotNull(databaseName);
74             mNamespace = Preconditions.checkNotNull(namespace);
75             mSchemaName = Preconditions.checkNotNull(schemaName);
76         }
77 
78         @Override
equals(@ullable Object o)79         public boolean equals(@Nullable Object o) {
80             if (this == o) {
81                 return true;
82             }
83             if (!(o instanceof DocumentChangeGroupKey)) {
84                 return false;
85             }
86             DocumentChangeGroupKey that = (DocumentChangeGroupKey) o;
87             return mPackageName.equals(that.mPackageName)
88                     && mDatabaseName.equals(that.mDatabaseName)
89                     && mNamespace.equals(that.mNamespace)
90                     && mSchemaName.equals(that.mSchemaName);
91         }
92 
93         @Override
hashCode()94         public int hashCode() {
95             return ObjectsCompat.hash(mPackageName, mDatabaseName, mNamespace, mSchemaName);
96         }
97     }
98 
99     private static final class ObserverInfo {
100         /** The package which registered the observer. */
101         final CallerAccess mListeningPackageAccess;
102         final ObserverSpec mObserverSpec;
103         final Executor mExecutor;
104         final ObserverCallback mObserverCallback;
105         // Values is a set of document IDs
106         volatile Map<DocumentChangeGroupKey, Set<String>> mDocumentChanges = new ArrayMap<>();
107         // Keys are database prefixes, values are a set of schema names
108         volatile Map<String, Set<String>> mSchemaChanges = new ArrayMap<>();
109 
ObserverInfo( @onNull CallerAccess listeningPackageAccess, @NonNull ObserverSpec observerSpec, @NonNull Executor executor, @NonNull ObserverCallback observerCallback)110         ObserverInfo(
111                 @NonNull CallerAccess listeningPackageAccess,
112                 @NonNull ObserverSpec observerSpec,
113                 @NonNull Executor executor,
114                 @NonNull ObserverCallback observerCallback) {
115             mListeningPackageAccess = Preconditions.checkNotNull(listeningPackageAccess);
116             mObserverSpec = Preconditions.checkNotNull(observerSpec);
117             mExecutor = Preconditions.checkNotNull(executor);
118             mObserverCallback = Preconditions.checkNotNull(observerCallback);
119         }
120     }
121 
122     private final Object mLock = new Object();
123 
124     /** Maps target packages to ObserverInfos watching something in that package. */
125     @GuardedBy("mLock")
126     private final Map<String, List<ObserverInfo>> mObserversLocked = new ArrayMap<>();
127 
128     private volatile boolean mHasNotifications = false;
129 
130     /**
131      * Adds an {@link ObserverCallback} to monitor changes within the databases owned by
132      * {@code targetPackageName} if they match the given
133      * {@link androidx.appsearch.observer.ObserverSpec}.
134      *
135      * <p>If the data owned by {@code targetPackageName} is not visible to you, the registration
136      * call will succeed but no notifications will be dispatched. Notifications could start flowing
137      * later if {@code targetPackageName} changes its schema visibility settings.
138      *
139      * <p>If no package matching {@code targetPackageName} exists on the system, the registration
140      * call will succeed but no notifications will be dispatched. Notifications could start flowing
141      * later if {@code targetPackageName} is installed and starts indexing data.
142      *
143      * <p>Note that this method does not take the standard read/write lock that guards I/O, so it
144      * will not queue behind I/O. Therefore it is safe to call from any thread including UI or
145      * binder threads.
146      *
147      * @param listeningPackageAccess Visibility information about the app that wants to receive
148      *                               notifications.
149      * @param targetPackageName      The package that owns the data the observerCallback wants to be
150      *                               notified for.
151      * @param spec                   Describes the kind of data changes the observerCallback should
152      *                               trigger for.
153      * @param executor               The executor on which to trigger the observerCallback callback
154      *                               to deliver notifications.
155      * @param observerCallback       The callback to trigger on notifications.
156      */
registerObserverCallback( @onNull CallerAccess listeningPackageAccess, @NonNull String targetPackageName, @NonNull ObserverSpec spec, @NonNull Executor executor, @NonNull ObserverCallback observerCallback)157     public void registerObserverCallback(
158             @NonNull CallerAccess listeningPackageAccess,
159             @NonNull String targetPackageName,
160             @NonNull ObserverSpec spec,
161             @NonNull Executor executor,
162             @NonNull ObserverCallback observerCallback) {
163         synchronized (mLock) {
164             List<ObserverInfo> infos = mObserversLocked.get(targetPackageName);
165             if (infos == null) {
166                 infos = new ArrayList<>();
167                 mObserversLocked.put(targetPackageName, infos);
168             }
169             infos.add(new ObserverInfo(listeningPackageAccess, spec, executor, observerCallback));
170         }
171     }
172 
173     /**
174      * Removes all observers that match via {@link ObserverCallback#equals} to the given observer
175      * from watching the targetPackageName.
176      *
177      * <p>Pending notifications queued for this observer, if any, are discarded.
178      */
unregisterObserverCallback( @onNull String targetPackageName, @NonNull ObserverCallback observer)179     public void unregisterObserverCallback(
180             @NonNull String targetPackageName, @NonNull ObserverCallback observer) {
181         synchronized (mLock) {
182             List<ObserverInfo> infos = mObserversLocked.get(targetPackageName);
183             if (infos == null) {
184                 return;
185             }
186             for (int i = 0; i < infos.size(); i++) {
187                 if (infos.get(i).mObserverCallback.equals(observer)) {
188                     infos.remove(i);
189                     i--;
190                 }
191             }
192         }
193     }
194 
195     /**
196      * Should be called when a change occurs to a document.
197      *
198      * <p>The notification will be queued in memory for later dispatch. You must call
199      * {@link #dispatchAndClearPendingNotifications} to dispatch all such pending notifications.
200      *
201      * @param visibilityStore   Store for visibility information. If not provided, only access to
202      *                          own data will be allowed.
203      * @param visibilityChecker Checker for visibility access. If not provided, only access to own
204      *                          data will be allowed.
205      */
onDocumentChange( @onNull String packageName, @NonNull String databaseName, @NonNull String namespace, @NonNull String schemaType, @NonNull String documentId, @Nullable VisibilityStore visibilityStore, @Nullable VisibilityChecker visibilityChecker)206     public void onDocumentChange(
207             @NonNull String packageName,
208             @NonNull String databaseName,
209             @NonNull String namespace,
210             @NonNull String schemaType,
211             @NonNull String documentId,
212             @Nullable VisibilityStore visibilityStore,
213             @Nullable VisibilityChecker visibilityChecker) {
214         synchronized (mLock) {
215             List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(packageName);
216             if (allObserverInfosForPackage == null || allObserverInfosForPackage.isEmpty()) {
217                 return; // No observers for this type
218             }
219             // Enqueue changes for later dispatch once the call returns
220             String prefixedSchema =
221                     PrefixUtil.createPrefix(packageName, databaseName) + schemaType;
222             DocumentChangeGroupKey key = null;
223             for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
224                 ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
225                 if (!matchesSpec(schemaType, observerInfo.mObserverSpec)) {
226                     continue;  // Observer doesn't want this notification
227                 }
228                 if (!VisibilityUtil.isSchemaSearchableByCaller(
229                         /*callerAccess=*/observerInfo.mListeningPackageAccess,
230                         /*targetPackageName=*/packageName,
231                         /*prefixedSchema=*/prefixedSchema,
232                         visibilityStore,
233                         visibilityChecker)) {
234                     continue;  // Observer can't have this notification.
235                 }
236                 if (key == null) {
237                     key = new DocumentChangeGroupKey(
238                             packageName, databaseName, namespace, schemaType);
239                 }
240                 Set<String> changedDocumentIds = observerInfo.mDocumentChanges.get(key);
241                 if (changedDocumentIds == null) {
242                     changedDocumentIds = new ArraySet<>();
243                     observerInfo.mDocumentChanges.put(key, changedDocumentIds);
244                 }
245                 changedDocumentIds.add(documentId);
246             }
247             mHasNotifications = true;
248         }
249     }
250 
251     /**
252      * Enqueues a change to a schema type for a single observer.
253      *
254      * <p>The notification will be queued in memory for later dispatch. You must call
255      * {@link #dispatchAndClearPendingNotifications} to dispatch all such pending notifications.
256      *
257      * <p>Note that unlike {@link #onDocumentChange}, the changes reported here are not dropped
258      * for observers that don't have visibility. This is because the observer might have had
259      * visibility before the schema change, and a final deletion needs to be sent to it. Caller
260      * is responsible for checking visibility of these notifications.
261      *
262      * @param listeningPackageName Name of package that subscribed to notifications and has been
263      *                             validated by the caller to have the right access to receive
264      *                             this notification.
265      * @param targetPackageName Name of package that owns the changed schema types.
266      * @param databaseName Database in which the changed schema types reside.
267      * @param schemaName Unprefixed name of the changed schema type.
268      */
onSchemaChange( @onNull String listeningPackageName, @NonNull String targetPackageName, @NonNull String databaseName, @NonNull String schemaName)269     public void onSchemaChange(
270             @NonNull String listeningPackageName,
271             @NonNull String targetPackageName,
272             @NonNull String databaseName,
273             @NonNull String schemaName) {
274         synchronized (mLock) {
275             List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(targetPackageName);
276             if (allObserverInfosForPackage == null || allObserverInfosForPackage.isEmpty()) {
277                 return; // No observers for this type
278             }
279             // Enqueue changes for later dispatch once the call returns
280             String prefix = null;
281             for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
282                 ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
283                 if (!observerInfo.mListeningPackageAccess.getCallingPackageName()
284                         .equals(listeningPackageName)) {
285                     continue;  // Not the observer we've been requested to update right now.
286                 }
287                 if (!matchesSpec(schemaName, observerInfo.mObserverSpec)) {
288                     continue;  // Observer doesn't want this notification
289                 }
290                 if (prefix == null) {
291                     prefix = PrefixUtil.createPrefix(targetPackageName, databaseName);
292                 }
293                 Set<String> changedSchemaNames = observerInfo.mSchemaChanges.get(prefix);
294                 if (changedSchemaNames == null) {
295                     changedSchemaNames = new ArraySet<>();
296                     observerInfo.mSchemaChanges.put(prefix, changedSchemaNames);
297                 }
298                 changedSchemaNames.add(schemaName);
299             }
300             mHasNotifications = true;
301         }
302     }
303 
304     /** Returns whether there are any observers registered to watch the given package. */
isPackageObserved(@onNull String packageName)305     public boolean isPackageObserved(@NonNull String packageName) {
306         synchronized (mLock) {
307             return mObserversLocked.containsKey(packageName);
308         }
309     }
310 
311     /**
312      * Returns whether there are any observers registered to watch the given package and
313      * unprefixed schema type.
314      */
isSchemaTypeObserved(@onNull String packageName, @NonNull String schemaType)315     public boolean isSchemaTypeObserved(@NonNull String packageName, @NonNull String schemaType) {
316         synchronized (mLock) {
317             List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(packageName);
318             if (allObserverInfosForPackage == null) {
319                 return false;
320             }
321             for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
322                 ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
323                 if (matchesSpec(schemaType, observerInfo.mObserverSpec)) {
324                     return true;
325                 }
326             }
327             return false;
328         }
329     }
330 
331     /**
332      * Returns package names of listening packages registered for changes on the given
333      * {@code packageName}, {@code databaseName} and unprefixed {@code schemaType}, only if they
334      * have access to that type according to the provided {@code visibilityChecker}.
335      */
getObserversForSchemaType( @onNull String packageName, @NonNull String databaseName, @NonNull String schemaType, @Nullable VisibilityStore visibilityStore, @Nullable VisibilityChecker visibilityChecker)336     public @NonNull Set<String> getObserversForSchemaType(
337             @NonNull String packageName,
338             @NonNull String databaseName,
339             @NonNull String schemaType,
340             @Nullable VisibilityStore visibilityStore,
341             @Nullable VisibilityChecker visibilityChecker) {
342         synchronized (mLock) {
343             List<ObserverInfo> allObserverInfosForPackage = mObserversLocked.get(packageName);
344             if (allObserverInfosForPackage == null) {
345                 return Collections.emptySet();
346             }
347             Set<String> result = new ArraySet<>();
348             String prefixedSchema = PrefixUtil.createPrefix(packageName, databaseName) + schemaType;
349             for (int i = 0; i < allObserverInfosForPackage.size(); i++) {
350                 ObserverInfo observerInfo = allObserverInfosForPackage.get(i);
351                 if (!matchesSpec(schemaType, observerInfo.mObserverSpec)) {
352                     continue;  // Observer doesn't want this notification
353                 }
354                 if (!VisibilityUtil.isSchemaSearchableByCaller(
355                         /*callerAccess=*/observerInfo.mListeningPackageAccess,
356                         /*targetPackageName=*/packageName,
357                         /*prefixedSchema=*/prefixedSchema,
358                         visibilityStore,
359                         visibilityChecker)) {
360                     continue;  // Observer can't have this notification.
361                 }
362                 result.add(observerInfo.mListeningPackageAccess.getCallingPackageName());
363             }
364             return result;
365         }
366     }
367 
368     /** Returns whether any notifications have been queued for dispatch. */
hasNotifications()369     public boolean hasNotifications() {
370         return mHasNotifications;
371     }
372 
373     /** Dispatches notifications on their corresponding executors. */
dispatchAndClearPendingNotifications()374     public void dispatchAndClearPendingNotifications() {
375         if (!mHasNotifications) {
376             return;
377         }
378         synchronized (mLock) {
379             if (mObserversLocked.isEmpty() || !mHasNotifications) {
380                 return;
381             }
382             for (List<ObserverInfo> observerInfos : mObserversLocked.values()) {
383                 for (int i = 0; i < observerInfos.size(); i++) {
384                     dispatchAndClearPendingNotificationsLocked(observerInfos.get(i));
385                 }
386             }
387             mHasNotifications = false;
388         }
389     }
390 
391     /** Dispatches pending notifications for the given observerInfo and clears the pending list. */
392     @GuardedBy("mLock")
dispatchAndClearPendingNotificationsLocked(@onNull ObserverInfo observerInfo)393     private void dispatchAndClearPendingNotificationsLocked(@NonNull ObserverInfo observerInfo) {
394         // Get and clear the pending changes
395         Map<String, Set<String>> schemaChanges = observerInfo.mSchemaChanges;
396         Map<DocumentChangeGroupKey, Set<String>> documentChanges = observerInfo.mDocumentChanges;
397         if (schemaChanges.isEmpty() && documentChanges.isEmpty()) {
398             // There is nothing to send, return early.
399             return;
400         }
401         // Clean the pending changes in the observer. We already copy pending changes to local
402         // variables.
403         observerInfo.mSchemaChanges = new ArrayMap<>();
404         observerInfo.mDocumentChanges = new ArrayMap<>();
405 
406         // Dispatch the pending changes
407         observerInfo.mExecutor.execute(() -> {
408             // Schema changes
409             if (!schemaChanges.isEmpty()) {
410                 for (Map.Entry<String, Set<String>> entry : schemaChanges.entrySet()) {
411                     SchemaChangeInfo schemaChangeInfo = new SchemaChangeInfo(
412                             /*packageName=*/PrefixUtil.getPackageName(entry.getKey()),
413                             /*databaseName=*/PrefixUtil.getDatabaseName(entry.getKey()),
414                             /*changedSchemaNames=*/entry.getValue());
415 
416                     try {
417                         observerInfo.mObserverCallback.onSchemaChanged(schemaChangeInfo);
418                     } catch (RuntimeException e) {
419                         Log.w(TAG, "ObserverCallback threw exception during dispatch", e);
420                         ExceptionUtil.handleException(e);
421                     }
422                 }
423             }
424 
425             // Document changes
426             if (!documentChanges.isEmpty()) {
427                 for (Map.Entry<DocumentChangeGroupKey, Set<String>> entry
428                         : documentChanges.entrySet()) {
429                     DocumentChangeInfo documentChangeInfo = new DocumentChangeInfo(
430                             entry.getKey().mPackageName,
431                             entry.getKey().mDatabaseName,
432                             entry.getKey().mNamespace,
433                             entry.getKey().mSchemaName,
434                             entry.getValue());
435 
436                     try {
437                         observerInfo.mObserverCallback.onDocumentChanged(documentChangeInfo);
438                     } catch (RuntimeException e) {
439                         Log.w(TAG, "ObserverCallback threw exception during dispatch", e);
440                         ExceptionUtil.handleException(e);
441                     }
442                 }
443             }
444         });
445     }
446 
447     /**
448      * Checks whether a change in the given {@code databaseName}, {@code namespace} and
449      * {@code schemaType} passes all the filters defined in the given {@code observerSpec}.
450      *
451      * <p>Note that this method does not check packageName; you must only use it to check
452      * observerSpecs which you know are observing the same package as the change.
453      */
matchesSpec( @onNull String schemaType, @NonNull ObserverSpec observerSpec)454     private static boolean matchesSpec(
455             @NonNull String schemaType, @NonNull ObserverSpec observerSpec) {
456         Set<String> schemaFilters = observerSpec.getFilterSchemas();
457         return schemaFilters.isEmpty() || schemaFilters.contains(schemaType);
458     }
459 }
460