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