| /* |
| * Copyright 2020 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. |
| */ |
| // @exportToFramework:skipFile() |
| package androidx.appsearch.localstorage; |
| |
| import android.content.Context; |
| import android.os.SystemClock; |
| import android.util.Log; |
| |
| import androidx.annotation.NonNull; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.RestrictTo; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.annotation.WorkerThread; |
| import androidx.appsearch.annotation.Document; |
| import androidx.appsearch.app.AppSearchSession; |
| import androidx.appsearch.app.GlobalSearchSession; |
| import androidx.appsearch.exceptions.AppSearchException; |
| import androidx.appsearch.localstorage.stats.InitializeStats; |
| import androidx.appsearch.localstorage.stats.OptimizeStats; |
| import androidx.appsearch.localstorage.util.FutureUtil; |
| import androidx.core.util.Preconditions; |
| |
| import com.google.common.util.concurrent.ListenableFuture; |
| |
| import java.io.File; |
| import java.util.concurrent.Executor; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| |
| /** |
| * An AppSearch storage system which stores data locally in the app's storage space using a bundled |
| * version of the search native library. |
| * |
| * <p>The search native library is an on-device searching library that allows apps to define |
| * {@link androidx.appsearch.app.AppSearchSchema}s, save and query a variety of |
| * {@link Document}s. The library needs to be initialized |
| * before using, which will create a folder to save data in the app's storage space. |
| * |
| * <p>Queries are executed multi-threaded, but a single thread is used for mutate requests (put, |
| * delete, etc..). |
| */ |
| public class LocalStorage { |
| private static final String TAG = "AppSearchLocalStorage"; |
| |
| private static final String ICING_LIB_ROOT_DIR = "appsearch"; |
| |
| /** Contains information about how to create the search session. */ |
| public static final class SearchContext { |
| final Context mContext; |
| final String mDatabaseName; |
| final Executor mExecutor; |
| @Nullable |
| final AppSearchLogger mLogger; |
| |
| SearchContext(@NonNull Context context, @NonNull String databaseName, |
| @NonNull Executor executor, @Nullable AppSearchLogger logger) { |
| mContext = Preconditions.checkNotNull(context); |
| mDatabaseName = Preconditions.checkNotNull(databaseName); |
| mExecutor = Preconditions.checkNotNull(executor); |
| mLogger = logger; |
| } |
| |
| /** |
| * Returns the name of the database to create or open. |
| */ |
| @NonNull |
| public String getDatabaseName() { |
| return mDatabaseName; |
| } |
| |
| /** |
| * Returns the worker executor associated with {@link AppSearchSession}. |
| * |
| * <p>If an executor is not provided to {@link Builder}, the AppSearch default executor will |
| * be returned. You should never cast the executor to |
| * {@link java.util.concurrent.ExecutorService} and call |
| * {@link ExecutorService#shutdownNow()}. It will cancel the futures it's returned. And |
| * since {@link Executor#execute} won't return anything, we will hang forever waiting for |
| * the execution. |
| */ |
| @NonNull |
| public Executor getWorkerExecutor() { |
| return mExecutor; |
| } |
| |
| /** Builder for {@link SearchContext} objects. */ |
| public static final class Builder { |
| private final Context mContext; |
| private final String mDatabaseName; |
| private Executor mExecutor; |
| @Nullable |
| private AppSearchLogger mLogger; |
| |
| /** |
| * Creates a {@link SearchContext.Builder} instance. |
| * |
| * <p>{@link AppSearchSession} will create or open a database under the given name. |
| * |
| * <p>Databases with different names are fully separate with distinct schema types, |
| * namespaces, and documents. |
| * |
| * <p>The database name cannot contain {@code '/'}. |
| * |
| * @param databaseName The name of the database. |
| * @throws IllegalArgumentException if the databaseName contains {@code '/'}. |
| */ |
| public Builder(@NonNull Context context, @NonNull String databaseName) { |
| mContext = Preconditions.checkNotNull(context); |
| Preconditions.checkNotNull(databaseName); |
| if (databaseName.contains("/")) { |
| throw new IllegalArgumentException("Database name cannot contain '/'"); |
| } |
| mDatabaseName = databaseName; |
| } |
| |
| /** |
| * Sets the worker executor associated with {@link AppSearchSession}. |
| * |
| * <p>If an executor is not provided, the AppSearch default executor will be used. |
| * |
| * @param executor the worker executor used to run heavy background tasks. |
| */ |
| @NonNull |
| public Builder setWorkerExecutor(@NonNull Executor executor) { |
| mExecutor = Preconditions.checkNotNull(executor); |
| return this; |
| } |
| |
| |
| /** |
| * Sets the custom logger used to get the details stats from AppSearch. |
| * |
| * <p>If no logger is provided, nothing would be returned/logged. There is no default |
| * logger implementation in AppSearch. |
| * |
| * @hide |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| @NonNull |
| public Builder setLogger(@NonNull AppSearchLogger logger) { |
| mLogger = Preconditions.checkNotNull(logger); |
| return this; |
| } |
| |
| /** Builds a {@link SearchContext} instance. */ |
| @NonNull |
| public SearchContext build() { |
| if (mExecutor == null) { |
| mExecutor = EXECUTOR; |
| } |
| return new SearchContext(mContext, mDatabaseName, mExecutor, mLogger); |
| } |
| } |
| } |
| |
| /** Contains information relevant to creating a global search session. */ |
| public static final class GlobalSearchContext { |
| final Context mContext; |
| final Executor mExecutor; |
| @Nullable |
| final AppSearchLogger mLogger; |
| |
| GlobalSearchContext(@NonNull Context context, @NonNull Executor executor, |
| @Nullable AppSearchLogger logger) { |
| mContext = Preconditions.checkNotNull(context); |
| mExecutor = Preconditions.checkNotNull(executor); |
| mLogger = logger; |
| } |
| |
| /** |
| * Returns the worker executor associated with {@link GlobalSearchSession}. |
| * |
| * <p>If an executor is not provided to {@link Builder}, the AppSearch default executor will |
| * be returned. You should never cast the executor to |
| * {@link java.util.concurrent.ExecutorService} and call |
| * {@link ExecutorService#shutdownNow()}. It will cancel the futures it's returned. And |
| * since {@link Executor#execute} won't return anything, we will hang forever waiting for |
| * the execution. |
| */ |
| @NonNull |
| public Executor getWorkerExecutor() { |
| return mExecutor; |
| } |
| |
| /** Builder for {@link GlobalSearchContext} objects. */ |
| public static final class Builder { |
| private final Context mContext; |
| private Executor mExecutor; |
| @Nullable |
| private AppSearchLogger mLogger; |
| |
| public Builder(@NonNull Context context) { |
| mContext = Preconditions.checkNotNull(context); |
| } |
| |
| /** |
| * Sets the worker executor associated with {@link GlobalSearchSession}. |
| * |
| * <p>If an executor is not provided, the AppSearch default executor will be used. |
| * |
| * @param executor the worker executor used to run heavy background tasks. |
| */ |
| @NonNull |
| public Builder setWorkerExecutor(@NonNull Executor executor) { |
| Preconditions.checkNotNull(executor); |
| mExecutor = executor; |
| return this; |
| } |
| |
| /** |
| * Sets the custom logger used to get the details stats from AppSearch. |
| * |
| * <p>If no logger is provided, nothing would be returned/logged. There is no default |
| * logger implementation in AppSearch. |
| * |
| * @hide |
| */ |
| @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) |
| @NonNull |
| public Builder setLogger(@NonNull AppSearchLogger logger) { |
| mLogger = Preconditions.checkNotNull(logger); |
| return this; |
| } |
| |
| /** Builds a {@link GlobalSearchContext} instance. */ |
| @NonNull |
| public GlobalSearchContext build() { |
| if (mExecutor == null) { |
| mExecutor = EXECUTOR; |
| } |
| return new GlobalSearchContext(mContext, mExecutor, mLogger); |
| } |
| } |
| } |
| |
| // AppSearch multi-thread execution is guarded by Read & Write Lock in AppSearchImpl, all |
| // mutate requests will need to gain write lock and query requests need to gain read lock. |
| static final Executor EXECUTOR = Executors.newCachedThreadPool(); |
| |
| private static volatile LocalStorage sInstance; |
| |
| private final AppSearchImpl mAppSearchImpl; |
| |
| /** |
| * Opens a new {@link AppSearchSession} on this storage with executor. |
| * |
| * <p>This process requires a native search library. If it's not created, the initialization |
| * process will create one. |
| * |
| * @param context The {@link SearchContext} contains all information to create a new |
| * {@link AppSearchSession} |
| */ |
| @NonNull |
| public static ListenableFuture<AppSearchSession> createSearchSessionAsync( |
| @NonNull SearchContext context) { |
| Preconditions.checkNotNull(context); |
| return FutureUtil.execute(context.mExecutor, () -> { |
| LocalStorage instance = getOrCreateInstance(context.mContext, context.mExecutor, |
| context.mLogger); |
| return instance.doCreateSearchSession(context); |
| }); |
| } |
| |
| /** |
| * Opens a new {@link GlobalSearchSession} on this storage. |
| * |
| * <p>The {@link GlobalSearchSession} opened from this {@link LocalStorage} allows the user to |
| * search across all local databases within the {@link LocalStorage} of this app, however |
| * cross-app search is not possible with {@link LocalStorage}. |
| * |
| * <p>This process requires a native search library. If it's not created, the initialization |
| * process will create one. |
| */ |
| @NonNull |
| public static ListenableFuture<GlobalSearchSession> createGlobalSearchSessionAsync( |
| @NonNull GlobalSearchContext context) { |
| Preconditions.checkNotNull(context); |
| return FutureUtil.execute(context.mExecutor, () -> { |
| LocalStorage instance = getOrCreateInstance(context.mContext, context.mExecutor, |
| context.mLogger); |
| return instance.doCreateGlobalSearchSession(context); |
| }); |
| } |
| |
| /** |
| * Returns the singleton instance of {@link LocalStorage}. |
| * |
| * <p>If the system is not initialized, it will be initialized using the provided |
| * {@code context}. |
| */ |
| @NonNull |
| @WorkerThread |
| @VisibleForTesting |
| static LocalStorage getOrCreateInstance(@NonNull Context context, @NonNull Executor executor, |
| @Nullable AppSearchLogger logger) |
| throws AppSearchException { |
| Preconditions.checkNotNull(context); |
| if (sInstance == null) { |
| synchronized (LocalStorage.class) { |
| if (sInstance == null) { |
| sInstance = new LocalStorage(context, executor, logger); |
| } |
| } |
| } |
| return sInstance; |
| } |
| |
| @WorkerThread |
| private LocalStorage( |
| @NonNull Context context, |
| @NonNull Executor executor, |
| @Nullable AppSearchLogger logger) |
| throws AppSearchException { |
| Preconditions.checkNotNull(context); |
| File icingDir = new File(context.getFilesDir(), ICING_LIB_ROOT_DIR); |
| |
| long totalLatencyStartMillis = SystemClock.elapsedRealtime(); |
| InitializeStats.Builder initStatsBuilder = null; |
| if (logger != null) { |
| initStatsBuilder = new InitializeStats.Builder(); |
| } |
| |
| // Syncing the current logging level to Icing before creating the AppSearch object, so that |
| // the correct logging level will cover the period of Icing initialization. |
| AppSearchImpl.syncLoggingLevelToIcing(); |
| mAppSearchImpl = AppSearchImpl.create( |
| icingDir, |
| new UnlimitedLimitConfig(), |
| new DefaultIcingOptionsConfig(), |
| initStatsBuilder, |
| new JetpackOptimizeStrategy(), |
| /*visibilityChecker=*/null); |
| |
| if (logger != null) { |
| initStatsBuilder.setTotalLatencyMillis( |
| (int) (SystemClock.elapsedRealtime() - totalLatencyStartMillis)); |
| logger.logStats(initStatsBuilder.build()); |
| } |
| |
| executor.execute(() -> { |
| long totalOptimizeLatencyStartMillis = SystemClock.elapsedRealtime(); |
| OptimizeStats.Builder builder = null; |
| try { |
| if (logger != null) { |
| builder = new OptimizeStats.Builder(); |
| } |
| mAppSearchImpl.checkForOptimize(builder); |
| } catch (AppSearchException e) { |
| Log.w(TAG, "Error occurred when check for optimize", e); |
| } finally { |
| if (builder != null) { |
| OptimizeStats oStats = builder |
| .setTotalLatencyMillis( |
| (int) (SystemClock.elapsedRealtime() |
| - totalOptimizeLatencyStartMillis)) |
| .build(); |
| if (logger != null && oStats.getOriginalDocumentCount() > 0) { |
| // see if optimize has been run by checking originalDocumentCount |
| logger.logStats(builder.build()); |
| } |
| } |
| } |
| }); |
| } |
| |
| @NonNull |
| private AppSearchSession doCreateSearchSession(@NonNull SearchContext context) { |
| return new SearchSessionImpl( |
| mAppSearchImpl, |
| context.mExecutor, |
| new AlwaysSupportedFeatures(), |
| context.mContext, |
| context.mDatabaseName, |
| context.mLogger); |
| } |
| |
| @NonNull |
| private GlobalSearchSession doCreateGlobalSearchSession( |
| @NonNull GlobalSearchContext context) { |
| return new GlobalSearchSessionImpl(mAppSearchImpl, context.mExecutor, |
| new AlwaysSupportedFeatures(), context.mContext, context.mLogger); |
| } |
| } |