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