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.shared.spe.JobServiceConstants.SCHEDULING_RESULT_CODE_FAILED;
25 import static com.android.adservices.shared.spe.JobServiceConstants.SCHEDULING_RESULT_CODE_SKIPPED;
26 import static com.android.adservices.shared.spe.JobServiceConstants.SCHEDULING_RESULT_CODE_SUCCESSFUL;
27 import static com.android.adservices.spe.AdServicesJobInfo.TOPICS_EPOCH_JOB;
28 
29 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
30 
31 import android.annotation.NonNull;
32 import android.app.job.JobInfo;
33 import android.app.job.JobParameters;
34 import android.app.job.JobScheduler;
35 import android.app.job.JobService;
36 import android.content.ComponentName;
37 import android.content.Context;
38 import android.os.Build;
39 
40 import androidx.annotation.RequiresApi;
41 
42 import com.android.adservices.LogUtil;
43 import com.android.adservices.LoggerFactory;
44 import com.android.adservices.concurrency.AdServicesExecutors;
45 import com.android.adservices.errorlogging.ErrorLogUtil;
46 import com.android.adservices.service.FlagsFactory;
47 import com.android.adservices.service.common.compat.ServiceCompatUtils;
48 import com.android.adservices.shared.common.ApplicationContextSingleton;
49 import com.android.adservices.shared.spe.JobServiceConstants.JobSchedulingResultCode;
50 import com.android.adservices.spe.AdServicesJobServiceLogger;
51 import com.android.internal.annotations.VisibleForTesting;
52 
53 import com.google.common.util.concurrent.FutureCallback;
54 import com.google.common.util.concurrent.Futures;
55 import com.google.common.util.concurrent.ListenableFuture;
56 
57 /** Epoch computation job. This will be run approximately once per epoch to compute Topics. */
58 @RequiresApi(Build.VERSION_CODES.S)
59 public final class EpochJobService extends JobService {
60     private static final int TOPICS_EPOCH_JOB_ID = TOPICS_EPOCH_JOB.getJobId();
61 
62     @Override
onStartJob(JobParameters params)63     public boolean onStartJob(JobParameters params) {
64         // Always ensure that the first thing this job does is check if it should be running, and
65         // cancel itself if it's not supposed to be.
66         if (ServiceCompatUtils.shouldDisableExtServicesJobOnTPlus(this)) {
67             LogUtil.d("Disabling EpochJobService job because it's running in ExtServices on T+");
68             return skipAndCancelBackgroundJob(params, /* skipReason= */ 0, /* doRecord= */ false);
69         }
70 
71         LoggerFactory.getTopicsLogger().d("EpochJobService.onStartJob");
72 
73         AdServicesJobServiceLogger.getInstance().recordOnStartJob(TOPICS_EPOCH_JOB_ID);
74 
75         if (FlagsFactory.getFlags().getTopicsKillSwitch()) {
76             ErrorLogUtil.e(
77                     AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_API_DISABLED,
78                     AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
79             LoggerFactory.getTopicsLogger()
80                     .e("Topics API is disabled, skipping and cancelling EpochJobService");
81             return skipAndCancelBackgroundJob(
82                     params,
83                     AD_SERVICES_BACKGROUND_JOBS_EXECUTION_REPORTED__EXECUTION_RESULT_CODE__SKIP_FOR_KILL_SWITCH_ON,
84                     /* doRecord= */ true);
85         }
86 
87         // This service executes each incoming job on a Handler running on the application's
88         // main thread. This means that we must offload the execution logic to background executor.
89         // TODO(b/225382268): Handle cancellation.
90         ListenableFuture<Void> epochComputationFuture =
91                 Futures.submit(
92                         () -> TopicsWorker.getInstance().computeEpoch(),
93                         AdServicesExecutors.getBackgroundExecutor());
94 
95         Futures.addCallback(
96                 epochComputationFuture,
97                 new FutureCallback<>() {
98                     @Override
99                     public void onSuccess(Void result) {
100                         LoggerFactory.getTopicsLogger().d("Epoch Computation succeeded!");
101 
102                         boolean shouldRetry = false;
103                         AdServicesJobServiceLogger.getInstance()
104                                 .recordJobFinished(
105                                         TOPICS_EPOCH_JOB_ID, /* isSuccessful= */ true, shouldRetry);
106 
107                         // Tell the JobScheduler that the job has completed and does not need to be
108                         // rescheduled.
109                         jobFinished(params, shouldRetry);
110                     }
111 
112                     @Override
113                     public void onFailure(Throwable t) {
114                         ErrorLogUtil.e(
115                                 t,
116                                 AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_HANDLE_JOB_SERVICE_FAILURE,
117                                 AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
118                         LoggerFactory.getTopicsLogger()
119                                 .e(t, "Failed to handle JobService: " + params.getJobId());
120 
121                         boolean shouldRetry = false;
122                         AdServicesJobServiceLogger.getInstance()
123                                 .recordJobFinished(
124                                         TOPICS_EPOCH_JOB_ID,
125                                         /* isSuccessful= */ false,
126                                         shouldRetry);
127 
128                         //  When failure, also tell the JobScheduler that the job has completed and
129                         // does not need to be rescheduled.
130                         // TODO(b/225909845): Revisit this. We need a retry policy.
131                         jobFinished(params, shouldRetry);
132                     }
133                 },
134                 directExecutor());
135 
136         // Reschedule jobs with SPE if it's enabled. Note scheduled jobs by this EpochJobService
137         // will be cancelled for the same job ID.
138         //
139         // Also for a job with Flex Period, it will NOT execute immediately after rescheduling it.
140         // Reschedule it here to let the execution complete and the next cycle will execute with
141         // the EpochJob.schedule().
142         if (FlagsFactory.getFlags().getSpeOnEpochJobEnabled()) {
143             LoggerFactory.getTopicsLogger()
144                     .d("SPE is enabled. Reschedule EpochJob with SPE framework.");
145             EpochJob.schedule();
146         }
147 
148         return true;
149     }
150 
151     @Override
onStopJob(JobParameters params)152     public boolean onStopJob(JobParameters params) {
153         LoggerFactory.getTopicsLogger().d("EpochJobService.onStopJob");
154 
155         // Tell JobScheduler not to reschedule the job because it's unknown at this stage if the
156         // execution is completed or not to avoid executing the task twice.
157         boolean shouldRetry = false;
158 
159         AdServicesJobServiceLogger.getInstance()
160                 .recordOnStopJob(params, TOPICS_EPOCH_JOB_ID, shouldRetry);
161         return shouldRetry;
162     }
163 
164     @VisibleForTesting
schedule( Context context, @NonNull JobScheduler jobScheduler, long epochJobPeriodMs, long epochJobFlexMs)165     static void schedule(
166             Context context,
167             @NonNull JobScheduler jobScheduler,
168             long epochJobPeriodMs,
169             long epochJobFlexMs) {
170         final JobInfo job =
171                 new JobInfo.Builder(
172                                 TOPICS_EPOCH_JOB_ID,
173                                 new ComponentName(context, EpochJobService.class))
174                         .setRequiresCharging(true)
175                         .setPersisted(true)
176                         .setPeriodic(epochJobPeriodMs, epochJobFlexMs)
177                         .build();
178 
179         jobScheduler.schedule(job);
180         LoggerFactory.getTopicsLogger().d("Scheduling Epoch job ...");
181     }
182 
183     /**
184      * Schedule Epoch Job Service if needed: there is no scheduled job with same job parameters.
185      *
186      * @param forceSchedule a flag to indicate whether to force rescheduling the job.
187      * @return a {@code boolean} to indicate if the service job is actually scheduled.
188      */
189     @JobSchedulingResultCode
scheduleIfNeeded(boolean forceSchedule)190     public static int scheduleIfNeeded(boolean forceSchedule) {
191         Context context = ApplicationContextSingleton.get();
192 
193         if (FlagsFactory.getFlags().getTopicsKillSwitch()) {
194             ErrorLogUtil.e(
195                     AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_API_DISABLED,
196                     AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
197             LoggerFactory.getTopicsLogger()
198                     .e("Topics API is disabled, skip scheduling the EpochJobService");
199             return SCHEDULING_RESULT_CODE_SKIPPED;
200         }
201 
202         final JobScheduler jobScheduler = context.getSystemService(JobScheduler.class);
203         if (jobScheduler == null) {
204             ErrorLogUtil.e(
205                     AD_SERVICES_ERROR_REPORTED__ERROR_CODE__TOPICS_FETCH_JOB_SCHEDULER_FAILURE,
206                     AD_SERVICES_ERROR_REPORTED__PPAPI_NAME__TOPICS);
207             LoggerFactory.getTopicsLogger().e("Cannot fetch Job Scheduler!");
208             return SCHEDULING_RESULT_CODE_FAILED;
209         }
210 
211         long flagsEpochJobPeriodMs = FlagsFactory.getFlags().getTopicsEpochJobPeriodMs();
212         long flagsEpochJobFlexMs = FlagsFactory.getFlags().getTopicsEpochJobFlexMs();
213 
214         JobInfo job = jobScheduler.getPendingJob(TOPICS_EPOCH_JOB_ID);
215         // Skip to reschedule the job if there is same scheduled job with same parameters.
216         if (job != null && !forceSchedule) {
217             long epochJobPeriodMs = job.getIntervalMillis();
218             long epochJobFlexMs = job.getFlexMillis();
219 
220             if (flagsEpochJobPeriodMs == epochJobPeriodMs
221                     && flagsEpochJobFlexMs == epochJobFlexMs) {
222                 LoggerFactory.getTopicsLogger()
223                         .i(
224                                 "Epoch Job Service has been scheduled with same parameters, skip"
225                                         + " rescheduling!");
226                 return SCHEDULING_RESULT_CODE_SKIPPED;
227             }
228         }
229 
230         schedule(context, jobScheduler, flagsEpochJobPeriodMs, flagsEpochJobFlexMs);
231         return SCHEDULING_RESULT_CODE_SUCCESSFUL;
232     }
233 
skipAndCancelBackgroundJob( final JobParameters params, int skipReason, boolean doRecord)234     private boolean skipAndCancelBackgroundJob(
235             final JobParameters params, int skipReason, boolean doRecord) {
236         JobScheduler jobScheduler = this.getSystemService(JobScheduler.class);
237         if (jobScheduler != null) {
238             jobScheduler.cancel(TOPICS_EPOCH_JOB_ID);
239         }
240 
241         if (doRecord) {
242             AdServicesJobServiceLogger.getInstance()
243                     .recordJobSkipped(TOPICS_EPOCH_JOB_ID, skipReason);
244         }
245 
246         // Tell the JobScheduler that the job has completed and does not need to be
247         // rescheduled.
248         jobFinished(params, false);
249 
250         // Returning false means that this job has completed its work.
251         return false;
252     }
253 }
254