/* * Copyright (C) 2024 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.appsindexer; import static com.android.server.appsearch.appsindexer.AppIndexerVersions.CURR_APP_INDEXER_VERSION; import static com.android.server.appsearch.indexer.IndexerMaintenanceConfig.APPS_INDEXER; import android.annotation.NonNull; import android.annotation.WorkerThread; import android.app.appsearch.AppSearchEnvironmentFactory; import android.app.appsearch.exceptions.AppSearchException; import android.content.Context; import android.os.SystemClock; import android.util.Log; import android.util.Slog; import com.android.appsearch.flags.Flags; import com.android.internal.annotations.VisibleForTesting; import com.android.server.appsearch.indexer.IndexerMaintenanceService; import com.android.server.appsearch.stats.AppSearchStatsLog; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.PrintWriter; import java.util.Objects; import java.util.concurrent.ExecutorService; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; /** * Apps Indexer for a single user. * *
It reads the updated/newly-inserted/deleted apps from PackageManager, and syncs the changes * into AppSearch. * *
This class is thread safe. * * @hide */ public final class AppsIndexerUserInstance { private static final String TAG = "AppSearchAppsIndexerUserInst"; private final File mDataDir; // While AppsIndexerSettings is not thread safe, it is only accessed through a single-threaded // executor service. It will be read and updated before the next scheduled task accesses it. private final AppsIndexerSettings mSettings; // Used for handling the app change notification so we won't schedule too many updates. At any // time, only two threads can run an update. But since we use a single-threaded executor, it // means that at most one thread can be running, and another thread can be waiting to run. This // will happen in the case that an update is requested while another is running. private final Semaphore mRunningOrScheduledSemaphore = new Semaphore(2); private AppsIndexerImpl mAppsIndexerImpl; /** * Single threaded executor to make sure there is only one active sync for this {@link * AppsIndexerUserInstance}. Background tasks should be scheduled using {@link * #executeOnSingleThreadedExecutor(Runnable)} which ensures that they are not executed if the * executor is shutdown during {@link #shutdown()}. * *
Note that this executor is used as both work and callback executors which is fine because * AppSearch should be able to handle exceptions thrown by them. */ private final ExecutorService mSingleThreadedExecutor; private final Context mContext; private final AppsIndexerConfig mAppsIndexerConfig; /** * Constructs and initializes a {@link AppsIndexerUserInstance}. * *
Heavy operations such as connecting to AppSearch are performed asynchronously. * * @param appsDir data directory for AppsIndexer. */ @NonNull public static AppsIndexerUserInstance createInstance( @NonNull Context userContext, @NonNull File appsDir, @NonNull AppsIndexerConfig appsIndexerConfig) throws AppSearchException { Objects.requireNonNull(userContext); Objects.requireNonNull(appsDir); Objects.requireNonNull(appsIndexerConfig); ExecutorService singleThreadedExecutor = AppSearchEnvironmentFactory.getEnvironmentInstance().createSingleThreadExecutor(); return createInstance(userContext, appsDir, appsIndexerConfig, singleThreadedExecutor); } @VisibleForTesting @NonNull static AppsIndexerUserInstance createInstance( @NonNull Context context, @NonNull File appsDir, @NonNull AppsIndexerConfig appsIndexerConfig, @NonNull ExecutorService executorService) throws AppSearchException { Objects.requireNonNull(context); Objects.requireNonNull(appsDir); Objects.requireNonNull(appsIndexerConfig); Objects.requireNonNull(executorService); AppsIndexerUserInstance indexer = new AppsIndexerUserInstance(appsDir, executorService, context, appsIndexerConfig); indexer.loadSettingsAsync(); indexer.mAppsIndexerImpl = new AppsIndexerImpl(context, appsIndexerConfig); return indexer; } /** * Constructs a {@link AppsIndexerUserInstance}. * * @param dataDir data directory for storing apps indexer state. * @param singleThreadedExecutor an {@link ExecutorService} with at most one thread to ensure * the thread safety of this class. * @param context Context object passed from {@link AppsIndexerManagerService} */ private AppsIndexerUserInstance( @NonNull File dataDir, @NonNull ExecutorService singleThreadedExecutor, @NonNull Context context, @NonNull AppsIndexerConfig appsIndexerConfig) { mDataDir = Objects.requireNonNull(dataDir); mSettings = new AppsIndexerSettings(mDataDir); mSingleThreadedExecutor = Objects.requireNonNull(singleThreadedExecutor); mContext = Objects.requireNonNull(context); mAppsIndexerConfig = Objects.requireNonNull(appsIndexerConfig); } /** Shuts down the AppsIndexerUserInstance */ public void shutdown() throws InterruptedException { mAppsIndexerImpl.close(); IndexerMaintenanceService.cancelUpdateJobIfScheduled( mContext, mContext.getUser(), APPS_INDEXER); synchronized (mSingleThreadedExecutor) { mSingleThreadedExecutor.shutdown(); } boolean unused = mSingleThreadedExecutor.awaitTermination(30L, TimeUnit.SECONDS); } /** Dumps the internal state of this {@link AppsIndexerUserInstance}. */ public void dump(@NonNull PrintWriter pw) { // Those timestamps are not protected by any lock since in AppsIndexerUserInstance // we only have one thread to handle all the updates. It is possible we might run into // race condition if there is an update running while those numbers are being printed. // This is acceptable though for debug purpose, so still no lock here. pw.println("last_update_timestamp_millis: " + mSettings.getLastUpdateTimestampMillis()); pw.println( "last_app_update_timestamp_millis: " + mSettings.getLastAppUpdateTimestampMillis()); } /** * Schedule an update. No new update can be scheduled if there are two updates already scheduled * or currently being run. * * @param firstRun boolean indicating if this is a first run and that settings should be checked * for the last update timestamp. */ public void updateAsync(boolean firstRun) { AppsUpdateStats appsUpdateStats = new AppsUpdateStats(); long updateLatencyStartTimestampMillis = SystemClock.elapsedRealtime(); appsUpdateStats.mUpdateStartTimestampMillis = System.currentTimeMillis(); appsUpdateStats.mUpdateType = AppsUpdateStats.FULL_UPDATE; // Try to acquire a permit. if (!mRunningOrScheduledSemaphore.tryAcquire()) { // If there are none available, that means an update is running and we have ALREADY // received a change mid-update. The third update request was received during the first // update, and will be handled by the scheduled update. return; } // If there is a permit available, that cold mean there is one update running right now // with none scheduled. Since we use a single threaded executor, calling execute on it // right now will run the requested update after the current update. It could also mean // there is no update running right now, so we can just call execute and run the update // right now. executeOnSingleThreadedExecutor( () -> { doUpdate(firstRun, appsUpdateStats); IndexerMaintenanceService.scheduleUpdateJob( mContext, mContext.getUser(), APPS_INDEXER, /* periodic= */ true, /* intervalMillis= */ mAppsIndexerConfig .getAppsMaintenanceUpdateIntervalMillis()); appsUpdateStats.mTotalLatencyMillis = SystemClock.elapsedRealtime() - updateLatencyStartTimestampMillis; logStats(appsUpdateStats); }); } /** * Does the update. It also releases a permit from {@link #mRunningOrScheduledSemaphore} * * @param firstRun when set to true, that means this was called from onUserUnlocking. If we * didn't have this check, the apps indexer would run every time the phone got unlocked. It * should only run the first time this happens. * @param appsUpdateStats contains stats about the apps indexer update. This method will * populate the fields of this {@link AppsUpdateStats} structure. */ @VisibleForTesting @WorkerThread void doUpdate(boolean firstRun, @NonNull AppsUpdateStats appsUpdateStats) { try { Objects.requireNonNull(appsUpdateStats); // Check if there was a prior run boolean isAppIndexerUpdated = Flags.enableAllPackageIndexingOnIndexerUpdate() && checkAndUpdateIndexerVersion(); if (firstRun) { if (Flags.enableAppsIndexerCheckPriorAttempt()) { // Special "firstRun" case. long now = System.currentTimeMillis(); long lastRun = mSettings.getLastAttemptedUpdateTimestampMillis(); long timeSinceLastRun = now - lastRun; // If timeSinceLastRun is somehow negative, it means that the system clock // must've turned back since the last run. We'll run the update in this case if (timeSinceLastRun >= 0 && timeSinceLastRun < mAppsIndexerConfig.getMinTimeBetweenFirstSyncsMillis()) { // Last firstRun was too recent, skip and leave timestamps alone return; } mSettings.setLastAttemptedUpdateTimestampMillis(now); mSettings.persist(); } // Check if there was a previous successful run and AppSearch wasn't updated since. if (mSettings.getLastUpdateTimestampMillis() != 0 && !isAppIndexerUpdated) { return; } } if (Flags.enableAppsIndexerIncrementalPut()) { mAppsIndexerImpl.doUpdateIncrementalPut( mSettings, appsUpdateStats, /* isFullUpdateRequired= */ isAppIndexerUpdated); } else { // TODO(b/367410454): Remove this method and related code paths once // enable_apps_indexer_incremental_put flag is rolled out. mAppsIndexerImpl.doUpdate(mSettings, appsUpdateStats); } mSettings.persist(); } catch (IOException e) { Log.w(TAG, "Failed to save settings to disk", e); } catch (AppSearchException e) { Log.e(TAG, "Failed to sync Apps to AppSearch", e); } finally { // Finish a update. If there were no permits available, the update that was requested // mid-update will run. If there was one permit available, we won't run another update. // This happens if no updates were scheduled during the update. mRunningOrScheduledSemaphore.release(); } } /** * Checks if the current App Indexer versionCode differs from the previously stored versionCode * in {@link AppsIndexerSettings} and updates the stored versionCode if necessary. * * @return {@code true} if the versionCode has changed, {@code false} otherwise. */ private boolean checkAndUpdateIndexerVersion() { if (mSettings.getPreviousIndexerVersionCode() == CURR_APP_INDEXER_VERSION) { return false; } mSettings.setPreviousIndexerVersionCode(CURR_APP_INDEXER_VERSION); return true; } /** * Loads the persisted data from disk. * *
It doesn't throw here. If it fails to load file, AppsIndexer would always use the * timestamps persisted in the memory. */ private void loadSettingsAsync() { executeOnSingleThreadedExecutor( () -> { try { // If the directory already exists, this returns false. That is fine as it // might not be the first sync. If this returns true, that is fine as it is // the first run and we want to make a new directory. mDataDir.mkdirs(); } catch (SecurityException e) { Log.e(TAG, "Failed to create settings directory on disk.", e); return; } try { mSettings.load(); } catch (IOException e) { // Ignore file not found errors (bootstrap case) if (!(e instanceof FileNotFoundException)) { Log.e(TAG, "Failed to load settings from disk", e); } } }); } /** * Executes the given command on {@link #mSingleThreadedExecutor} if it is still alive. * *
If the {@link #mSingleThreadedExecutor} has been shutdown, this method doesn't execute the * given command, and returns silently. Specifically, it does not throw {@link * java.util.concurrent.RejectedExecutionException}. * * @param command the runnable task */ private void executeOnSingleThreadedExecutor(Runnable command) { synchronized (mSingleThreadedExecutor) { if (mSingleThreadedExecutor.isShutdown()) { Log.w(TAG, "Executor is shutdown, not executing task"); return; } mSingleThreadedExecutor.execute( () -> { try { command.run(); } catch (RuntimeException e) { Slog.wtf( TAG, "AppsIndexerUserInstance" + ".executeOnSingleThreadedExecutor() failed ", e); } }); } } private void logStats(@NonNull AppsUpdateStats appsUpdateStats) { Objects.requireNonNull(appsUpdateStats); int[] updateStatusArr = new int[appsUpdateStats.mUpdateStatusCodes.size()]; int updateIdx = 0; for (int updateStatus : appsUpdateStats.mUpdateStatusCodes) { updateStatusArr[updateIdx] = updateStatus; ++updateIdx; } AppSearchStatsLog.write( AppSearchStatsLog.APP_SEARCH_APPS_INDEXER_STATS_REPORTED, appsUpdateStats.mUpdateType, updateStatusArr, appsUpdateStats.mNumberOfAppsAdded, appsUpdateStats.mNumberOfAppsRemoved, appsUpdateStats.mNumberOfAppsUpdated, appsUpdateStats.mNumberOfAppsUnchanged, appsUpdateStats.mTotalLatencyMillis, appsUpdateStats.mPackageManagerLatencyMillis, appsUpdateStats.mAppSearchGetLatencyMillis, appsUpdateStats.mAppSearchSetSchemaLatencyMillis, appsUpdateStats.mAppSearchPutLatencyMillis, appsUpdateStats.mUpdateStartTimestampMillis, appsUpdateStats.mLastAppUpdateTimestampMillis, appsUpdateStats.mNumberOfFunctionsAdded, appsUpdateStats.mApproximateNumberOfFunctionsRemoved, appsUpdateStats.mNumberOfFunctionsUpdated, appsUpdateStats.mApproximateNumberOfFunctionsUnchanged, appsUpdateStats.mAppSearchRemoveLatencyMillis); } }