1 /*
2  * Copyright (C) 2024 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 com.android.server.appsearch.indexer;
18 
19 import android.annotation.NonNull;
20 import android.annotation.Nullable;
21 import android.app.appsearch.AppSearchEnvironmentFactory;
22 import android.app.appsearch.annotation.CanIgnoreReturnValue;
23 import android.app.appsearch.util.LogUtil;
24 import android.app.job.JobInfo;
25 import android.app.job.JobParameters;
26 import android.app.job.JobScheduler;
27 import android.app.job.JobService;
28 import android.content.ComponentName;
29 import android.content.Context;
30 import android.os.CancellationSignal;
31 import android.os.PersistableBundle;
32 import android.os.UserHandle;
33 import android.util.ArrayMap;
34 import android.util.Log;
35 import android.util.Slog;
36 
37 import com.android.internal.annotations.GuardedBy;
38 import com.android.internal.annotations.VisibleForTesting;
39 import com.android.server.LocalManagerRegistry;
40 import com.android.server.appsearch.contactsindexer.ContactsIndexerMaintenanceService;
41 import com.android.server.appsearch.indexer.IndexerMaintenanceConfig.IndexerType;
42 
43 import java.util.Map;
44 import java.util.Objects;
45 import java.util.concurrent.Executor;
46 import java.util.concurrent.LinkedBlockingQueue;
47 import java.util.concurrent.TimeUnit;
48 
49 /** Dispatches maintenance tasks for various indexers. */
50 public class IndexerMaintenanceService extends JobService {
51     private static final String TAG = "AppSearchIndexerMaintena";
52     private static final String EXTRA_USER_ID = "user_id";
53     private static final String INDEXER_TYPE = "indexer_type";
54 
55     /**
56      * A mapping of userHandle-to-CancellationSignal. Since we schedule a separate job for each
57      * user, this JobService might be executing simultaneously for the various users, so we need to
58      * keep track of the cancellation signal for each user update so we stop the appropriate update
59      * when necessary.
60      */
61     @GuardedBy("mSignals")
62     private final Map<UserHandle, CancellationSignal> mSignals = new ArrayMap<>();
63 
64     private final Executor mExecutor =
65             AppSearchEnvironmentFactory.getEnvironmentInstance()
66                     .createExecutorService(
67                             /* corePoolSize= */ 1,
68                             /* maximumPoolSize= */ 1,
69                             /* keepAliveTime= */ 60L,
70                             /* unit= */ TimeUnit.SECONDS,
71                             /* workQueue= */ new LinkedBlockingQueue<>(),
72                             /* priority= */ 0); // priority is unused.
73 
74     /**
75      * Schedules an update job for the given device-user.
76      *
77      * @param userHandle Device user handle for whom the update job should be scheduled.
78      * @param periodic True to indicate that the job should be repeated.
79      * @param indexerType Indicates which {@link IndexerType} to schedule an update for.
80      * @param intervalMillis Millisecond interval for which this job should repeat.
81      */
scheduleUpdateJob( @onNull Context context, @NonNull UserHandle userHandle, @IndexerType int indexerType, boolean periodic, long intervalMillis)82     public static void scheduleUpdateJob(
83             @NonNull Context context,
84             @NonNull UserHandle userHandle,
85             @IndexerType int indexerType,
86             boolean periodic,
87             long intervalMillis) {
88         Objects.requireNonNull(context);
89         Objects.requireNonNull(userHandle);
90         int jobId = getJobIdForUser(userHandle, indexerType);
91         JobScheduler jobScheduler = context.getSystemService(JobScheduler.class);
92         // For devices U and below, we have to schedule using ContactsIndexerMaintenanceService
93         // as it has the proper permissions in core/res/AndroidManifest.xml.
94         // IndexerMaintenanceService does not have the proper permissions on U. For simplicity, we
95         // can also use the same component for scheduling maintenance on U+.
96         ComponentName component =
97                 new ComponentName(context, ContactsIndexerMaintenanceService.class);
98 
99         final PersistableBundle extras = new PersistableBundle();
100         extras.putInt(EXTRA_USER_ID, userHandle.getIdentifier());
101         extras.putInt(INDEXER_TYPE, indexerType);
102         JobInfo.Builder jobInfoBuilder =
103                 new JobInfo.Builder(jobId, component)
104                         .setExtras(extras)
105                         .setRequiresBatteryNotLow(true)
106                         .setRequiresDeviceIdle(true)
107                         .setPersisted(true);
108 
109         if (periodic) {
110             // Specify a flex value of 1/2 the interval so that the job is scheduled to run
111             // in the [interval/2, interval) time window, assuming the other conditions are
112             // met. This avoids the scenario where the next update job is started within
113             // a short duration of the previous run.
114             jobInfoBuilder.setPeriodic(intervalMillis, /* flexMillis= */ intervalMillis / 2);
115         }
116         JobInfo jobInfo = jobInfoBuilder.build();
117         JobInfo pendingJobInfo = jobScheduler.getPendingJob(jobId);
118         // Don't reschedule a pending job if the parameters haven't changed.
119         if (jobInfo.equals(pendingJobInfo)) {
120             return;
121         }
122         jobScheduler.schedule(jobInfo);
123         if (LogUtil.DEBUG) {
124             Log.v(TAG, "Scheduled update job " + jobId + " for user " + userHandle);
125         }
126     }
127 
128     /**
129      * Cancel update job for the given user.
130      *
131      * @param userHandle The user handle for whom the update job needs to be cancelled.
132      */
cancelUpdateJob( @onNull Context context, @NonNull UserHandle userHandle, @IndexerType int indexerType)133     private static void cancelUpdateJob(
134             @NonNull Context context,
135             @NonNull UserHandle userHandle,
136             @IndexerType int indexerType) {
137         Objects.requireNonNull(context);
138         Objects.requireNonNull(userHandle);
139         int jobId = getJobIdForUser(userHandle, indexerType);
140         JobScheduler jobScheduler = context.getSystemService(JobScheduler.class);
141         jobScheduler.cancel(jobId);
142         if (LogUtil.DEBUG) {
143             Log.v(TAG, "Canceled update job " + jobId + " for user " + userHandle);
144         }
145     }
146 
147     /**
148      * Check if a update job is scheduled for the given user.
149      *
150      * @param userHandle The user handle for whom the check for scheduled job needs to be performed
151      * @return true if a scheduled job exists
152      */
isUpdateJobScheduled( @onNull Context context, @NonNull UserHandle userHandle, @IndexerType int indexerType)153     public static boolean isUpdateJobScheduled(
154             @NonNull Context context,
155             @NonNull UserHandle userHandle,
156             @IndexerType int indexerType) {
157         Objects.requireNonNull(context);
158         Objects.requireNonNull(userHandle);
159         int jobId = getJobIdForUser(userHandle, indexerType);
160         JobScheduler jobScheduler = context.getSystemService(JobScheduler.class);
161         return jobScheduler.getPendingJob(jobId) != null;
162     }
163 
164     /**
165      * Cancel any scheduled update job for the given user. Checks if a update job for the given user
166      * exists before trying to cancel it.
167      *
168      * @param user The user for whom the update job needs to be cancelled.
169      */
cancelUpdateJobIfScheduled( @onNull Context context, @NonNull UserHandle user, @IndexerType int indexerType)170     public static void cancelUpdateJobIfScheduled(
171             @NonNull Context context, @NonNull UserHandle user, @IndexerType int indexerType) {
172         Objects.requireNonNull(context);
173         Objects.requireNonNull(user);
174         try {
175             if (isUpdateJobScheduled(context, user, indexerType)) {
176                 cancelUpdateJob(context, user, indexerType);
177             }
178         } catch (RuntimeException e) {
179             Log.e(TAG, "Failed to cancel pending update job ", e);
180         }
181     }
182 
183     /**
184      * Generate job ids in the range (MIN_INDEXER_JOB_ID, MAX_INDEXER_JOB_ID) to avoid conflicts
185      * with other jobs scheduled by the system service. The range corresponds to 21475 job ids,
186      * which is the maximum number of user ids in the system.
187      *
188      * @see com.android.server.pm.UserManagerService#MAX_USER_ID
189      */
getJobIdForUser( @onNull UserHandle userHandle, @IndexerType int indexerType)190     private static int getJobIdForUser(
191             @NonNull UserHandle userHandle, @IndexerType int indexerType) {
192         Objects.requireNonNull(userHandle);
193         int baseJobId = IndexerMaintenanceConfig.getConfigForIndexer(indexerType).getMinJobId();
194         return baseJobId + userHandle.getIdentifier();
195     }
196 
197     @Override
onStartJob(JobParameters params)198     public boolean onStartJob(JobParameters params) {
199         try {
200             int userId = params.getExtras().getInt(EXTRA_USER_ID, /* defaultValue= */ -1);
201             if (userId == -1) {
202                 return false;
203             }
204 
205             @IndexerType
206             int indexerType = params.getExtras().getInt(INDEXER_TYPE, /* defaultValue= */ -1);
207             if (indexerType == -1) {
208                 return false;
209             }
210 
211             if (LogUtil.DEBUG) {
212                 Log.v(TAG, "Update job started for user " + userId);
213             }
214 
215             UserHandle userHandle = UserHandle.getUserHandleForUid(userId);
216             final CancellationSignal oldSignal;
217             synchronized (mSignals) {
218                 oldSignal = mSignals.get(userHandle);
219             }
220             if (oldSignal != null) {
221                 // This could happen if we attempt to schedule a new job for the user while there's
222                 // one already running.
223                 Log.w(TAG, "Old update job still running for user " + userHandle);
224                 oldSignal.cancel();
225             }
226             final CancellationSignal signal = new CancellationSignal();
227             synchronized (mSignals) {
228                 mSignals.put(userHandle, signal);
229             }
230             mExecutor.execute(() -> doUpdateForUser(this, params, userHandle, signal));
231             return true;
232         } catch (RuntimeException e) {
233             Slog.wtf(TAG, "IndexerMaintenanceService.onStartJob() failed ", e);
234             return false;
235         }
236     }
237 
238     /**
239      * Triggers update from a background job for the given device-user using {@link
240      * ContactsIndexerManagerService.LocalService} manager.
241      *
242      * @param params Parameters from the job that triggered the update.
243      * @param userHandle Device user handle for whom the update job should be triggered.
244      * @param signal Used to indicate if the update task should be cancelled.
245      * @return A boolean representing whether the update operation completed or encountered an
246      *     issue. This return value is only used for testing purposes.
247      */
248     @VisibleForTesting
249     @CanIgnoreReturnValue
doUpdateForUser( @onNull Context context, @Nullable JobParameters params, @NonNull UserHandle userHandle, @NonNull CancellationSignal signal)250     public boolean doUpdateForUser(
251             @NonNull Context context,
252             @Nullable JobParameters params,
253             @NonNull UserHandle userHandle,
254             @NonNull CancellationSignal signal) {
255         try {
256             Objects.requireNonNull(context);
257             Objects.requireNonNull(userHandle);
258             Objects.requireNonNull(signal);
259 
260             @IndexerType int indexerType = params.getExtras().getInt(INDEXER_TYPE, -1);
261             if (indexerType == -1) {
262                 return false;
263             }
264             Class<? extends IndexerLocalService> indexerLocalService =
265                     IndexerMaintenanceConfig.getConfigForIndexer(indexerType).getLocalService();
266             IndexerLocalService service = LocalManagerRegistry.getManager(indexerLocalService);
267             if (service == null) {
268                 Log.e(
269                         TAG,
270                         "Background job failed to trigger Update because "
271                                 + "Indexer.LocalService is not available.");
272                 // If a background update job exists while an indexer is disabled, cancel the
273                 // job after its first run. This will prevent any periodic jobs from being
274                 // unnecessarily triggered repeatedly. If the service is null, it means the indexer
275                 // is disabled. So the local service is not registered during the startup.
276                 cancelUpdateJob(context, userHandle, indexerType);
277                 return false;
278             }
279             service.doUpdateForUser(userHandle, signal);
280         } catch (RuntimeException e) {
281             Log.e(TAG, "Background job failed to trigger Update because ", e);
282             return false;
283         } finally {
284             jobFinished(params, signal.isCanceled());
285             synchronized (mSignals) {
286                 if (signal == mSignals.get(userHandle)) {
287                     mSignals.remove(userHandle);
288                 }
289             }
290         }
291         return true;
292     }
293 
294     @Override
onStopJob(JobParameters params)295     public boolean onStopJob(JobParameters params) {
296         try {
297             final int userId = params.getExtras().getInt(EXTRA_USER_ID, /* defaultValue */ -1);
298             if (userId == -1) {
299                 return false;
300             }
301             UserHandle userHandle = UserHandle.getUserHandleForUid(userId);
302             // This will only run on S+ builds, so no need to do a version check.
303             if (LogUtil.DEBUG) {
304                 Log.d(
305                         TAG,
306                         "Stopping update job for user "
307                                 + userId
308                                 + " because "
309                                 + params.getStopReason());
310             }
311             synchronized (mSignals) {
312                 final CancellationSignal signal = mSignals.get(userHandle);
313                 if (signal != null) {
314                     signal.cancel();
315                     mSignals.remove(userHandle);
316                     // We had to stop the job early. Request reschedule.
317                     return true;
318                 }
319             }
320             Log.e(TAG, "JobScheduler stopped an update that wasn't happening...");
321             return false;
322         } catch (RuntimeException e) {
323             Slog.wtf(TAG, "IndexerMaintenanceService.onStopJob() failed ", e);
324             return false;
325         }
326     }
327 }
328