• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 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.adservices.service.topics;
18 
19 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_BACKGROUND_JOBS_EXECUTION_REPORTED__EXECUTION_RESULT_CODE__SKIP_FOR_KILL_SWITCH_ON;
20 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_API_DISABLED;
21 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_FETCH_JOB_SCHEDULER_FAILURE;
22 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_HANDLE_JOB_SERVICE_FAILURE;
23 import static com.android.adservices.service.stats.AdServicesStatsLog.AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS;
24 import static com.android.adservices.service.stats.AdsRelevanceStatusUtils.TOPICS_RESCHEDULE_EPOCH_JOB_STATUS_SKIP_RESCHEDULE_EMPTY_JOB_SCHEDULER;
25 import static com.android.adservices.service.stats.AdsRelevanceStatusUtils.TOPICS_RESCHEDULE_EPOCH_JOB_STATUS_SKIP_RESCHEDULE_EMPTY_PENDING_JOB;
26 import static com.android.adservices.shared.spe.JobServiceConstants.SCHEDULING_RESULT_CODE_FAILED;
27 import static com.android.adservices.shared.spe.JobServiceConstants.SCHEDULING_RESULT_CODE_SKIPPED;
28 import static com.android.adservices.shared.spe.JobServiceConstants.SCHEDULING_RESULT_CODE_SUCCESSFUL;
29 import static com.android.adservices.spe.AdServicesJobInfo.TOPICS_EPOCH_JOB;
30 
31 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
32 
33 import android.annotation.NonNull;
34 import android.app.job.JobInfo;
35 import android.app.job.JobParameters;
36 import android.app.job.JobScheduler;
37 import android.app.job.JobService;
38 import android.content.ComponentName;
39 import android.content.Context;
40 import android.os.Build;
41 
42 import androidx.annotation.RequiresApi;
43 
44 import com.android.adservices.LogUtil;
45 import com.android.adservices.LoggerFactory;
46 import com.android.adservices.concurrency.AdServicesExecutors;
47 import com.android.adservices.errorlogging.ErrorLogUtil;
48 import com.android.adservices.service.FlagsFactory;
49 import com.android.adservices.service.common.compat.ServiceCompatUtils;
50 import com.android.adservices.service.stats.TopicsScheduleEpochJobSettingReportedStatsLogger;
51 import com.android.adservices.shared.common.ApplicationContextSingleton;
52 import com.android.adservices.shared.spe.JobServiceConstants.JobSchedulingResultCode;
53 import com.android.adservices.spe.AdServicesJobServiceLogger;
54 import com.android.internal.annotations.VisibleForTesting;
55 
56 import com.google.common.util.concurrent.FutureCallback;
57 import com.google.common.util.concurrent.Futures;
58 import com.google.common.util.concurrent.ListenableFuture;
59 
60 import java.util.ArrayList;
61 
62 /** Epoch computation job. This will be run approximately once per epoch to compute Topics. */
63 @RequiresApi(Build.VERSION_CODES.S)
64 public final class EpochJobService extends JobService {
65     private static final int TOPICS_EPOCH_JOB_ID = TOPICS_EPOCH_JOB.getJobId();
66 
67     @Override
onStartJob(JobParameters params)68     public boolean onStartJob(JobParameters params) {
69         // Always ensure that the first thing this job does is check if it should be running, and
70         // cancel itself if it's not supposed to be.
71         if (ServiceCompatUtils.shouldDisableExtServicesJobOnTPlus(this)) {
72             LogUtil.d("Disabling EpochJobService job because it's running in ExtServices on T+");
73             return skipAndCancelBackgroundJob(params, /* skipReason= */ 0, /* doRecord= */ false);
74         }
75 
76         LoggerFactory.getTopicsLogger().d("EpochJobService.onStartJob");
77 
78         AdServicesJobServiceLogger.getInstance().recordOnStartJob(TOPICS_EPOCH_JOB_ID);
79 
80         if (FlagsFactory.getFlags().getTopicsKillSwitch()) {
81             ErrorLogUtil.e(
82                     AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_API_DISABLED,
83                     AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
84             LoggerFactory.getTopicsLogger()
85                     .e("Topics API is disabled, skipping and cancelling EpochJobService");
86             return skipAndCancelBackgroundJob(
87                     params,
88                     AD_SERVICES_BACKGROUND_JOBS_EXECUTION_REPORTED__EXECUTION_RESULT_CODE__SKIP_FOR_KILL_SWITCH_ON,
89                     /* doRecord= */ true);
90         }
91 
92         // This service executes each incoming job on a Handler running on the application's
93         // main thread. This means that we must offload the execution logic to background executor.
94         // TODO(b/225382268): Handle cancellation.
95         ListenableFuture<Void> epochComputationFuture =
96                 Futures.submit(
97                         () -> TopicsWorker.getInstance().computeEpoch(),
98                         AdServicesExecutors.getBackgroundExecutor());
99 
100         Futures.addCallback(
101                 epochComputationFuture,
102                 new FutureCallback<>() {
103                     @Override
104                     public void onSuccess(Void result) {
105                         LoggerFactory.getTopicsLogger().d("Epoch Computation succeeded!");
106 
107                         boolean shouldRetry = false;
108                         AdServicesJobServiceLogger.getInstance()
109                                 .recordJobFinished(
110                                         TOPICS_EPOCH_JOB_ID, /* isSuccessful= */ true, shouldRetry);
111 
112                         // Tell the JobScheduler that the job has completed and does not need to be
113                         // rescheduled.
114                         jobFinished(params, shouldRetry);
115                     }
116 
117                     @Override
118                     public void onFailure(Throwable t) {
119                         ErrorLogUtil.e(
120                                 t,
121                                 AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_HANDLE_JOB_SERVICE_FAILURE,
122                                 AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
123                         LoggerFactory.getTopicsLogger()
124                                 .e(t, "Failed to handle JobService: " + params.getJobId());
125 
126                         boolean shouldRetry = false;
127                         AdServicesJobServiceLogger.getInstance()
128                                 .recordJobFinished(
129                                         TOPICS_EPOCH_JOB_ID,
130                                         /* isSuccessful= */ false,
131                                         shouldRetry);
132 
133                         //  When failure, also tell the JobScheduler that the job has completed and
134                         // does not need to be rescheduled.
135                         // TODO(b/225909845): Revisit this. We need a retry policy.
136                         jobFinished(params, shouldRetry);
137                     }
138                 },
139                 directExecutor());
140 
141         if (FlagsFactory.getFlags().getTopicsJobSchedulerRescheduleEnabled()) {
142             // Reschedule Topics Epoch job if the charging setting of previous epoch job is changed.
143             rescheduleEpochJob();
144         }
145 
146         // Reschedule jobs with SPE if it's enabled. Note scheduled jobs by this EpochJobService
147         // will be cancelled for the same job ID.
148         //
149         // Also for a job with Flex Period, it will NOT execute immediately after rescheduling it.
150         // Reschedule it here to let the execution complete and the next cycle will execute with
151         // the EpochJob.schedule().
152         if (FlagsFactory.getFlags().getSpeOnEpochJobEnabled()) {
153             LoggerFactory.getTopicsLogger()
154                     .d("SPE is enabled. Reschedule EpochJob with SPE framework.");
155             EpochJob.schedule();
156         }
157 
158         return true;
159     }
160 
161     @Override
onStopJob(JobParameters params)162     public boolean onStopJob(JobParameters params) {
163         LoggerFactory.getTopicsLogger().d("EpochJobService.onStopJob");
164 
165         // Tell JobScheduler not to reschedule the job because it's unknown at this stage if the
166         // execution is completed or not to avoid executing the task twice.
167         boolean shouldRetry = false;
168 
169         AdServicesJobServiceLogger.getInstance()
170                 .recordOnStopJob(params, TOPICS_EPOCH_JOB_ID, shouldRetry);
171         return shouldRetry;
172     }
173 
174     @VisibleForTesting
schedule( @onNull JobScheduler jobScheduler, JobInfo jobInfo)175     static void schedule(
176             @NonNull JobScheduler jobScheduler,
177             JobInfo jobInfo) {
178 
179         LoggerFactory.getTopicsLogger().d(
180                 "EpochJobService requires charging: "
181                         + jobInfo.isRequireCharging()
182                         + "\nEpochJobService requires battery not low: "
183                         + jobInfo.isRequireBatteryNotLow()
184                         + "\nEpochJobService epoch length (ms): "
185                         + jobInfo.getIntervalMillis()
186                         + "\nEpochJobService flex time (ms): "
187                         + jobInfo.getFlexMillis());
188 
189         jobScheduler.schedule(jobInfo);
190         LoggerFactory.getTopicsLogger().d("Scheduling Epoch job ...");
191     }
192 
193     @VisibleForTesting
getJobInfo()194     static JobInfo getJobInfo() {
195         Context context = ApplicationContextSingleton.get();
196         JobInfo.Builder jobInfoBuilder =
197                 new JobInfo.Builder(
198                         TOPICS_EPOCH_JOB_ID,
199                         new ComponentName(context, EpochJobService.class))
200                         .setPersisted(true)
201                         .setPeriodic(
202                                 FlagsFactory.getFlags().getTopicsEpochJobPeriodMs(),
203                                 FlagsFactory.getFlags().getTopicsEpochJobFlexMs());
204 
205         boolean flagsTopicsEpochJobBatteryNotLowInsteadOfCharging =
206                 FlagsFactory.getFlags().getTopicsEpochJobBatteryNotLowInsteadOfCharging();
207 
208         if (flagsTopicsEpochJobBatteryNotLowInsteadOfCharging) {
209             jobInfoBuilder
210                     .setRequiresCharging(false)
211                     .setRequiresBatteryNotLow(true);
212         } else {
213             jobInfoBuilder
214                     .setRequiresCharging(true)
215                     .setRequiresBatteryNotLow(false);
216         }
217 
218         return jobInfoBuilder.build();
219     }
220 
221     /**
222      * Schedule Epoch Job Service if needed: there is no scheduled job with same job parameters.
223      *
224      * @param forceSchedule a flag to indicate whether to force rescheduling the job.
225      * @return a {@code boolean} to indicate if the service job is actually scheduled.
226      */
227     @JobSchedulingResultCode
scheduleIfNeeded(boolean forceSchedule)228     public static int scheduleIfNeeded(boolean forceSchedule) {
229         return scheduleIfNeededCalledFromRescheduleEpochJob(
230                 forceSchedule,
231                 TopicsScheduleEpochJobSettingReportedStatsLogger.getInstance());
232     }
233 
234     /**
235      * Schedule Epoch Job Service if needed: there is no scheduled job with same job parameters.
236      *
237      * @param forceSchedule a flag to indicate whether to force rescheduling the job.
238      * @param topicsScheduleEpochJobSettingReportedStatsLogger a class for Topics schedule epoch job
239      *                                                         setting logger.
240      * @return a {@code boolean} to indicate if the service job is actually scheduled.
241      */
242     @JobSchedulingResultCode
scheduleIfNeededCalledFromRescheduleEpochJob( boolean forceSchedule, TopicsScheduleEpochJobSettingReportedStatsLogger topicsScheduleEpochJobSettingReportedStatsLogger)243     public static int scheduleIfNeededCalledFromRescheduleEpochJob(
244             boolean forceSchedule,
245             TopicsScheduleEpochJobSettingReportedStatsLogger
246                     topicsScheduleEpochJobSettingReportedStatsLogger) {
247         Context context = ApplicationContextSingleton.get();
248 
249         if (FlagsFactory.getFlags().getTopicsKillSwitch()) {
250             ErrorLogUtil.e(
251                     AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_API_DISABLED,
252                     AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
253             LoggerFactory.getTopicsLogger()
254                     .e("Topics API is disabled, skip scheduling the EpochJobService");
255             return SCHEDULING_RESULT_CODE_SKIPPED;
256         }
257 
258         final JobScheduler jobScheduler = context.getSystemService(JobScheduler.class);
259         if (jobScheduler == null) {
260             ErrorLogUtil.e(
261                     AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_FETCH_JOB_SCHEDULER_FAILURE,
262                     AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
263             LoggerFactory.getTopicsLogger().e("Cannot fetch Job Scheduler!");
264             return SCHEDULING_RESULT_CODE_FAILED;
265         }
266 
267         JobInfo scheduledJobInfo = jobScheduler.getPendingJob(TOPICS_EPOCH_JOB_ID);
268         JobInfo newJobInfo = getJobInfo();
269 
270         if (scheduledJobInfo == null || forceSchedule) {
271             topicsScheduleEpochJobSettingReportedStatsLogger.logScheduleIfNeeded();
272             schedule(jobScheduler, newJobInfo);
273             LoggerFactory.getTopicsLogger().v(
274                     "Topics Epoch Job Service is scheduled successfully "
275                             + "because no pending job in jobScheduler or forceSchedule is true.");
276             return SCHEDULING_RESULT_CODE_SUCCESSFUL;
277         } else {
278             if (newJobInfo.equals(scheduledJobInfo)) {
279                 // Skip to reschedule the job if there is same scheduled job with same parameters.
280                 LoggerFactory.getTopicsLogger().v(
281                         "Epoch Job Service has been scheduled with same parameters, "
282                                 + "skip rescheduling!");
283                 return SCHEDULING_RESULT_CODE_SKIPPED;
284             } else {
285                 // Clear all topics data when epoch job's configuration is changed.
286                 if (FlagsFactory.getFlags()
287                         .getTopicsCleanDBWhenEpochJobSettingsChanged()) {
288                     LoggerFactory.getTopicsLogger().v(
289                             "Cleaning Topics DB because epoch job's configuration is changed.");
290                     TopicsWorker.getInstance().clearAllTopicsData(new ArrayList<>());
291                 }
292                 LoggerFactory.getTopicsLogger().v(
293                         "Rescheduling Topics epoch job because its configuration is changed.");
294                 LoggerFactory.getTopicsLogger().d(
295                         "EpochJobPeriodMs in pending epoch job is: "
296                                 + scheduledJobInfo.getIntervalMillis()
297                                 + ", new epoch job is: "
298                                 + newJobInfo.getIntervalMillis() +
299                                 "\nEpochJobFlexMs in pending epoch job is: "
300                                 + scheduledJobInfo.getFlexMillis()
301                                 + ", new epoch job is: "
302                                 + newJobInfo.getFlexMillis() +
303                                 "\nRequires battery not low in pending epoch job is: "
304                                 + scheduledJobInfo.isRequireBatteryNotLow()
305                                 + ", new epoch job is: "
306                                 + newJobInfo.isRequireBatteryNotLow());
307                 topicsScheduleEpochJobSettingReportedStatsLogger.logScheduleIfNeeded();
308                 schedule(jobScheduler, newJobInfo);
309                 return SCHEDULING_RESULT_CODE_SUCCESSFUL;
310             }
311         }
312     }
313 
skipAndCancelBackgroundJob( final JobParameters params, int skipReason, boolean doRecord)314     private boolean skipAndCancelBackgroundJob(
315             final JobParameters params, int skipReason, boolean doRecord) {
316         JobScheduler jobScheduler = this.getSystemService(JobScheduler.class);
317         if (jobScheduler != null) {
318             jobScheduler.cancel(TOPICS_EPOCH_JOB_ID);
319         }
320 
321         if (doRecord) {
322             AdServicesJobServiceLogger.getInstance()
323                     .recordJobSkipped(TOPICS_EPOCH_JOB_ID, skipReason);
324         }
325 
326         // Tell the JobScheduler that the job has completed and does not need to be
327         // rescheduled.
328         jobFinished(params, false);
329 
330         // Returning false means that this job has completed its work.
331         return false;
332     }
333 
334     @VisibleForTesting
rescheduleEpochJob()335     static void rescheduleEpochJob() {
336         JobScheduler jobScheduler =
337                 ApplicationContextSingleton.get().getSystemService(JobScheduler.class);
338         JobInfo previousEpochJobInfo = null;
339         // The default EpochJob doesn't require battery not low, but requires charging.
340         boolean isScheduledEpochJobRequireBatteryNotLow = false;
341 
342         TopicsScheduleEpochJobSettingReportedStatsLogger
343                 topicsScheduleEpochJobSettingReportedStatsLogger =
344                 TopicsScheduleEpochJobSettingReportedStatsLogger.getInstance();
345 
346         if (jobScheduler != null) {
347             previousEpochJobInfo = jobScheduler.getPendingJob(TOPICS_EPOCH_JOB_ID);
348         } else {
349             LoggerFactory.getTopicsLogger().d(
350                     "There is no existing JobScheduler, skip rescheduleEpochJob.");
351             topicsScheduleEpochJobSettingReportedStatsLogger
352                     .logSkipRescheduleEpochJob(
353                             TOPICS_RESCHEDULE_EPOCH_JOB_STATUS_SKIP_RESCHEDULE_EMPTY_JOB_SCHEDULER);
354             return;
355         }
356 
357         boolean flagTopicsEpochJobBatteryNotLowInsteadOfCharging =
358                 FlagsFactory.getFlags().getTopicsEpochJobBatteryNotLowInsteadOfCharging();
359         if (previousEpochJobInfo != null) {
360             isScheduledEpochJobRequireBatteryNotLow = previousEpochJobInfo.isRequireBatteryNotLow();
361             // If the battery not low setting of an EpochJob is changed,
362             // the EpochJob should be rescheduled.
363             if (isScheduledEpochJobRequireBatteryNotLow
364                     != flagTopicsEpochJobBatteryNotLowInsteadOfCharging) {
365                 topicsScheduleEpochJobSettingReportedStatsLogger
366                         .setPreviousEpochJobStatus(isScheduledEpochJobRequireBatteryNotLow);
367                 scheduleIfNeededCalledFromRescheduleEpochJob(
368                         true, topicsScheduleEpochJobSettingReportedStatsLogger);
369                 LoggerFactory.getTopicsLogger().d(
370                         "Rescheduled EpochJobService because requires "
371                                 + "battery not low is changed to: "
372                                 + flagTopicsEpochJobBatteryNotLowInsteadOfCharging);
373             }
374         } else {
375             LoggerFactory.getTopicsLogger().d(
376                     "There is no existing pending epoch job, skip rescheduleEpochJob.");
377             topicsScheduleEpochJobSettingReportedStatsLogger
378                     .logSkipRescheduleEpochJob(
379                             TOPICS_RESCHEDULE_EPOCH_JOB_STATUS_SKIP_RESCHEDULE_EMPTY_PENDING_JOB);
380         }
381     }
382 }
383