1 /* 2 * Copyright (C) 2023 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 android.ext.services.common; 18 19 20 import android.annotation.SuppressLint; 21 import android.annotation.TargetApi; 22 import android.app.job.JobInfo; 23 import android.app.job.JobParameters; 24 import android.app.job.JobScheduler; 25 import android.app.job.JobService; 26 import android.content.ComponentName; 27 import android.content.Context; 28 import android.content.SharedPreferences; 29 import android.util.Log; 30 31 import androidx.annotation.VisibleForTesting; 32 import androidx.appsearch.app.AppSearchSession; 33 import androidx.appsearch.app.SearchResults; 34 import androidx.appsearch.app.SearchSpec; 35 import androidx.appsearch.app.SetSchemaRequest; 36 import androidx.appsearch.app.SetSchemaResponse; 37 import androidx.appsearch.platformstorage.PlatformStorage; 38 39 import com.google.common.util.concurrent.FluentFuture; 40 import com.google.common.util.concurrent.Futures; 41 import com.google.common.util.concurrent.ListenableFuture; 42 43 import java.util.List; 44 import java.util.concurrent.ExecutionException; 45 import java.util.concurrent.Executor; 46 import java.util.concurrent.Executors; 47 import java.util.concurrent.TimeUnit; 48 import java.util.concurrent.TimeoutException; 49 50 /** 51 * Background Period Job that deletes Appsearch Data after OTA To T and the data has been migrated 52 * to System Server. First time it runs, it stores the timestamp and check if Appsearch has any data 53 * and if not, cancels itself. Next time, it checks if the maximum allowed time from OTA to keep the 54 * Appsearch data has passed and if so deletes the data and cancels itself. Else it checks 55 * if minimum time to the device has AdServices enabled in Flags, has passed. If so, it checks 56 * if the AdServices is enabled and the first time it finds enabled, the job stores the timestamp. 57 * Next runs it keeps checking if the minimum allowed time to run the data migration after enabling 58 * AdServices has passed and if so deletes the Appsearch data and cancels itself. 59 **/ 60 public class AdServicesAppsearchDeleteJob extends JobService { 61 62 private static final String TAG = "extservices"; 63 public static final int JOB_ID = 27; // The job id matches the placeholder in AdServicesJobInfo 64 private static final String KEY_EXT_ADSERVICES_APPSEARCH_PREFS = 65 "ext_adservices_appsearch_delete_job_prefs"; 66 67 static final String SHARED_PREFS_KEY_OTA_DATE = "ota_date"; 68 static final String SHARED_PREFS_KEY_APPSEARCH_DATA_FOUND = "appsearch_data_found"; 69 static final String SHARED_PREFS_KEY_ADSERVICES_ENABLED_DATE = "adservices_enabled_date"; 70 71 static final String SHARED_PREFS_KEY_ADSERVICES_APPSEARCH_DELETED = 72 "adservices_appsearch_deleted"; 73 74 static final String SHARED_PREFS_KEY_ATTEMPTED_DELETE_COUNT = 75 "attempted_delete_count"; 76 static final String SHARED_PREFS_KEY_JOB_RUN_COUNT = "job_run_count"; 77 78 private static final String CONSENT_DATABASE_NAME = "adservices_consent"; 79 private static final String APP_CONSENT_DATABASE_NAME = "adservices_app_consent"; 80 private static final String NOTIFICATION_DATABASE_NAME = "adservices_notification"; 81 private static final String INTERACTIONS_DATABASE_NAME = "adservices_interactions"; 82 private static final String TOPICS_DATABASE_NAME = "adservices-topics"; 83 private static final String UX_STATES_DATABASE_NAME = "adservices-ux-states"; 84 private static final String MEASUREMENT_ROLLBACK_DATABASE_NAME = "measurement_rollback"; 85 86 private static final List<String> AD_SERVICES_APPSEARCH_DBS_TO_DELETE = List.of( 87 CONSENT_DATABASE_NAME, 88 APP_CONSENT_DATABASE_NAME, 89 NOTIFICATION_DATABASE_NAME, 90 INTERACTIONS_DATABASE_NAME, 91 TOPICS_DATABASE_NAME, 92 UX_STATES_DATABASE_NAME, 93 MEASUREMENT_ROLLBACK_DATABASE_NAME); 94 95 private final Executor mExecutor = Executors.newCachedThreadPool(); 96 97 @Override onStartJob(JobParameters params)98 public boolean onStartJob(JobParameters params) { 99 int jobId = params.getJobId(); 100 Log.i(TAG, "AdServicesAppsearchDeleteJobService invoked with job id: " 101 + jobId); 102 SharedPreferences sharedPref = getSharedPreferences(); 103 SharedPreferences.Editor editor = sharedPref.edit(); 104 105 try { 106 AdservicesPhFlags adservicesPhFlags = getAdservicesPhFlags(); 107 if (!adservicesPhFlags.isAppsearchDeleteJobEnabled()) { 108 Log.d(TAG, 109 "AdServicesAppsearchDeleteJobService is not enabled in config," 110 + " cancelling job id: " + params.getJobId()); 111 cancelPeriodicJob(this, params); 112 return false; 113 } 114 if (adservicesPhFlags.shouldDoNothingAdServicesAppsearchDeleteJob()) { 115 Log.d(TAG, 116 "AdServicesAppsearchDeleteJobService is set to do nothing in config," 117 + " returning.... "); 118 return false; 119 } 120 121 long jobRunCount = sharedPref.getLong(SHARED_PREFS_KEY_JOB_RUN_COUNT, 122 /* defaultValue= */ 0L) + 1; 123 Log.d(TAG, 124 "AdServicesAppsearchDeleteJobService job run count is " + jobRunCount); 125 editor.putLong(SHARED_PREFS_KEY_JOB_RUN_COUNT, jobRunCount); 126 127 long otaDate = sharedPref.getLong(SHARED_PREFS_KEY_OTA_DATE, 0L); 128 // Check if the job is run first time after OTA 129 if (otaDate == 0L) { 130 long currentTime = System.currentTimeMillis(); 131 Log.d(TAG, 132 "AdServicesAppsearchDeleteJobService OTA to T " 133 + " on : " + currentTime); 134 editor.putLong(SHARED_PREFS_KEY_OTA_DATE, currentTime); 135 boolean foundData = !isAppsearchDbEmpty(this, mExecutor, 136 NOTIFICATION_DATABASE_NAME); 137 editor.putBoolean(SHARED_PREFS_KEY_APPSEARCH_DATA_FOUND, foundData); 138 Log.d(TAG, "AdServicesAppsearchDeleteJobService found data in Appsearch: " 139 + foundData); 140 if (!foundData) { 141 cancelPeriodicJob(this, params); 142 } 143 } else if (hasMinMinutesPassed(otaDate, 144 adservicesPhFlags.getMinMinutesFromOtaToDeleteAppsearchData())) { 145 Log.d(TAG, "Deleting Appsearch Data as maximum allowed time passed " 146 + "from OTA"); 147 deleteAppsearchData(params, editor, sharedPref, 148 adservicesPhFlags.getMaxAppsearchAdServicesDeleteAttempts()); 149 } else if (!hasMinMinutesPassed(otaDate, 150 adservicesPhFlags.getMinMinutesFromOtaToCheckAdServicesStatus())) { 151 Log.d(TAG, "Minimum time to check AdServices status from OTA " 152 + "has not passed, returning...."); 153 } else { 154 long adServicesEnabledDate = sharedPref 155 .getLong(SHARED_PREFS_KEY_ADSERVICES_ENABLED_DATE, 0L); 156 boolean adServicesEnabled = adservicesPhFlags.isAdServicesEnabled(); 157 if (!adServicesEnabled) { 158 // restart the timer 159 editor.putLong(SHARED_PREFS_KEY_ADSERVICES_ENABLED_DATE, 0L); 160 Log.d(TAG, 161 "AdServicesAppsearchDeleteJobService found " 162 + "AdServices Disabled"); 163 } else if (adServicesEnabledDate == 0L) { 164 long currentTime = System.currentTimeMillis(); 165 editor.putLong(SHARED_PREFS_KEY_ADSERVICES_ENABLED_DATE, currentTime); 166 Log.d(TAG, "AdServicesAppsearchDeleteJobService found " 167 + "AdServices Enabled on : " + currentTime); 168 } else if (hasMinMinutesPassed(adServicesEnabledDate, 169 adservicesPhFlags.getMinMinutesToDeleteFromAdServicesEnabled())) { 170 Log.d(TAG, "Deleting Appsearch Data after verifying minimum time passed " 171 + "from AdServices enabled"); 172 deleteAppsearchData(params, editor, sharedPref, 173 adservicesPhFlags.getMaxAppsearchAdServicesDeleteAttempts()); 174 } else { 175 Log.d(TAG, "Not Deleting Appsearch Data as minimum time" 176 + " has not passed from AdServices enabled"); 177 } 178 } 179 180 } catch (Exception e) { 181 Log.e(TAG, "Exception in AdServicesAppsearchDeleteJob " + e); 182 } 183 184 if (!editor.commit()) { 185 Log.e(TAG, "AdServicesAppsearchDeleteJob could not commit shared prefs"); 186 } 187 return false; 188 } 189 deleteAppsearchData(JobParameters params, SharedPreferences.Editor editor, SharedPreferences sharedPreferences, int maxAttempts)190 private void deleteAppsearchData(JobParameters params, SharedPreferences.Editor editor, 191 SharedPreferences sharedPreferences, int maxAttempts) { 192 193 if (deleteAppsearchDbs(this, mExecutor, AD_SERVICES_APPSEARCH_DBS_TO_DELETE)) { 194 Log.d(TAG, 195 "AdServicesAppsearchDeleteJobService deleted data in Appsearch " 196 + " cancelling future runs of job id: " 197 + params.getJobId()); 198 editor.putLong(SHARED_PREFS_KEY_ADSERVICES_APPSEARCH_DELETED, 199 System.currentTimeMillis()); 200 cancelPeriodicJob(this, params); 201 } else { 202 int attemptedDeletes = sharedPreferences 203 .getInt(SHARED_PREFS_KEY_ATTEMPTED_DELETE_COUNT, 0) + 1; 204 editor.putInt(SHARED_PREFS_KEY_ATTEMPTED_DELETE_COUNT, attemptedDeletes); 205 Log.e(TAG, 206 "AdServicesAppsearchDeleteJobService did not delete" 207 + " all Appsearch dbs on attempt " + attemptedDeletes); 208 if (attemptedDeletes >= maxAttempts) { 209 Log.e(TAG, "Max attempts to deletes has been reached, cancelling future jobs"); 210 cancelPeriodicJob(this, params); 211 } 212 } 213 } 214 215 @Override onStopJob(JobParameters params)216 public boolean onStopJob(JobParameters params) { 217 Log.d(TAG, "AdServicesAppsearchDeleteJobService onStopJob invoked with " 218 + "job id " + params.getJobId()); 219 return false; 220 } 221 222 /** 223 * checks if data is empty in Appsearch database 224 * 225 * @param context android context 226 * @param executor Executor service 227 * @param db Appsearch db to check data is empty 228 * @return {@code true} Appsearch database is empty; else {@code false}. 229 **/ 230 @VisibleForTesting 231 @TargetApi(31) isAppsearchDbEmpty(Context context, Executor executor, String db)232 public boolean isAppsearchDbEmpty(Context context, Executor executor, String db) 233 throws TimeoutException, ExecutionException, InterruptedException { 234 final ListenableFuture<AppSearchSession> appSearchSession = 235 PlatformStorage.createSearchSessionAsync( 236 new PlatformStorage.SearchContext.Builder(context, db).build()); 237 238 ListenableFuture<SearchResults> searchFuture = 239 Futures.transform( 240 appSearchSession, 241 session -> session.search("", new SearchSpec.Builder().build()), 242 executor); 243 FluentFuture<Integer> future = 244 FluentFuture.from(searchFuture) 245 .transformAsync( 246 results -> 247 Futures.transform(results.getNextPageAsync(), 248 List::size, executor), 249 executor); 250 int resultsSize = future.get(500, TimeUnit.MILLISECONDS); 251 Log.d(TAG, "Appsearch found results of size " + resultsSize); 252 return resultsSize == 0; 253 } 254 255 /** 256 * Deletes App search Database by calling setForceOverride true on the schemaRequest 257 * 258 * @param context the android context 259 * @param executor Executor 260 * @param dbs the list of database names to be deleted 261 * @return {@code true} deletion was a success; else {@code false}. 262 **/ 263 @VisibleForTesting deleteAppsearchDbs(Context context, Executor executor, List<String> dbs)264 public boolean deleteAppsearchDbs(Context context, Executor executor, List<String> dbs) { 265 int successCount = 0; 266 for (String appsearchDb : dbs) { 267 if (deleteAppsearchDb(context, executor, appsearchDb)) successCount++; 268 } 269 boolean success = successCount == dbs.size(); 270 Log.d(TAG, "AdServicesAppsearchDeleteJobService Complete with success " + successCount 271 + " out of " + dbs.size() + ",success status is " + success); 272 return success; 273 274 } 275 276 /** 277 * Deletes App search Database 278 * 279 * @param context the android context 280 * @param executor Executor 281 * @param db the database name to be deleted 282 * @return {@code true} deletion was a success; else {@code false}. 283 **/ 284 @VisibleForTesting deleteAppsearchDb(Context context, Executor executor, String db)285 public boolean deleteAppsearchDb(Context context, Executor executor, String db) { 286 Log.d(TAG, "Deleting AdServices Appsearch db " + db); 287 try { 288 SetSchemaResponse setSchemaResponse = getDeleteSchemaResponse(context, 289 executor, 290 db); 291 if (!setSchemaResponse.getMigrationFailures().isEmpty()) { 292 Log.e(TAG, 293 "Delete failed for AdServices Appsearch db " + db 294 + " , SetSchemaResponse migration failure: " 295 + setSchemaResponse 296 .getMigrationFailures() 297 .get(0)); 298 return false; 299 } 300 Log.d(TAG, "Delete types size " + setSchemaResponse.getDeletedTypes().size()); 301 for (String deletedType : setSchemaResponse.getDeletedTypes()) { 302 Log.d(TAG, "Deleted type is " + deletedType); 303 } 304 Log.d(TAG, "Delete successful for AdServices Appsearch db " + db); 305 return true; 306 307 } catch (Exception e) { 308 Log.e(TAG, "Delete failed for AdServices Appsearch db " + db + " " + e); 309 return false; 310 } 311 } 312 313 /** 314 * Creates the appSearch session and calls schema request to setForceOverride on a database 315 * to delete it. 316 * 317 * @param context the android context 318 * @param executor executor service 319 * @param db database name to delete 320 * @return SetSchemaResponse from executing the schema request to delete db 321 **/ 322 @VisibleForTesting 323 @TargetApi(31) getDeleteSchemaResponse(Context context, Executor executor, String db)324 public SetSchemaResponse getDeleteSchemaResponse(Context context, Executor executor, 325 String db) throws InterruptedException, TimeoutException, ExecutionException { 326 final ListenableFuture<AppSearchSession> appSearchSession = 327 PlatformStorage.createSearchSessionAsync( 328 new PlatformStorage.SearchContext.Builder(context, db).build()); 329 SetSchemaRequest setSchemaRequest = new SetSchemaRequest.Builder().setForceOverride( 330 true).build(); 331 return FluentFuture.from(appSearchSession) 332 .transformAsync( 333 session -> session.setSchemaAsync(setSchemaRequest), executor) 334 .get(500, TimeUnit.MILLISECONDS); 335 } 336 337 /** 338 * Checks if the give date in string has passed the minutes compared to current date 339 * 340 * @param timestampToCheckInMillis date time in millis to check against 341 * @param minMinutesToCheck minimum minutes 342 **/ 343 @VisibleForTesting hasMinMinutesPassed(long timestampToCheckInMillis, long minMinutesToCheck)344 public boolean hasMinMinutesPassed(long timestampToCheckInMillis, 345 long minMinutesToCheck) { 346 long currentTimestamp = System.currentTimeMillis(); 347 long millisToMinutes = 60000L; 348 long minutesPassed = (currentTimestamp - timestampToCheckInMillis) 349 / millisToMinutes; 350 Log.d(TAG, "The minutes from current date " + currentTimestamp 351 + " to dateToCheck " + timestampToCheckInMillis 352 + " is " + minutesPassed 353 + " and minimum minutes to check " + minMinutesToCheck); 354 return minutesPassed >= minMinutesToCheck; 355 } 356 357 358 /** 359 * Cancels the current periodic job and sets the job to not reschedule 360 * 361 * @param context the android context 362 * @param params the job params 363 **/ 364 @VisibleForTesting cancelPeriodicJob(Context context, JobParameters params)365 public void cancelPeriodicJob(Context context, JobParameters params) { 366 final JobScheduler jobScheduler = context.getSystemService(JobScheduler.class); 367 if (jobScheduler != null) { 368 int jobId = params.getJobId(); 369 jobScheduler.cancel(jobId); 370 Log.d(TAG, "AdServicesAppsearchDeletePeriodicJobService cancelled job " 371 + jobId); 372 } 373 setReschedule(params, false); 374 } 375 376 /** 377 * call the parent jobFinished method with setting the re-schedule flag 378 * 379 * @param jobParameters job params 380 * @param reschedule whether to reschedule the job 381 **/ 382 @VisibleForTesting setReschedule(JobParameters jobParameters, boolean reschedule)383 public void setReschedule(JobParameters jobParameters, boolean reschedule) { 384 jobFinished(jobParameters, reschedule); 385 } 386 387 /** 388 * returns the instance of Adservices Ph flags object 389 **/ 390 @VisibleForTesting getAdservicesPhFlags()391 public AdservicesPhFlags getAdservicesPhFlags() { 392 return new AdservicesPhFlags(); 393 } 394 395 /** 396 * returns the shared prefs object 397 **/ 398 @VisibleForTesting getSharedPreferences()399 SharedPreferences getSharedPreferences() { 400 return this.getSharedPreferences(KEY_EXT_ADSERVICES_APPSEARCH_PREFS, 401 Context.MODE_PRIVATE); 402 } 403 404 /** 405 * Schedules AdServicesAppsearchDeleteJob run periodically to check the 406 * AdServices status and then create the actual delete job to delete all the AdServices 407 * app search data. 408 * 409 * @param context the android context 410 **/ 411 @SuppressLint("MissingPermission") scheduleAdServicesAppsearchDeletePeriodicJob( Context context, AdservicesPhFlags adservicesPhFlags)412 public static void scheduleAdServicesAppsearchDeletePeriodicJob( 413 Context context, AdservicesPhFlags adservicesPhFlags) { 414 try { 415 Log.d(TAG, 416 "Scheduling AdServicesAppsearchDeleteJobService ..."); 417 418 if (!adservicesPhFlags.isAppsearchDeleteJobEnabled()) { 419 Log.d(TAG, 420 "AdServicesAppsearchDeleteJobService periodic job disabled in " 421 + "config, Cancelling Scheduling ..."); 422 return; 423 } 424 425 final JobScheduler jobScheduler = context.getSystemService(JobScheduler.class); 426 if (jobScheduler == null) { 427 Log.e(TAG, "AdServicesAppsearchDeleteJobService JobScheduler is null"); 428 return; 429 } 430 final JobInfo oldJob = jobScheduler.getPendingJob(JOB_ID); 431 if (oldJob != null) { 432 Log.d(TAG, "AdServicesAppsearchDeleteJobService already scheduled" 433 + " with job id:" + JOB_ID 434 + ", skipping reschedule"); 435 return; 436 } 437 JobInfo.Builder jobInfoBuild = new JobInfo.Builder(JOB_ID, 438 new ComponentName(context, AdServicesAppsearchDeleteJob.class)); 439 jobInfoBuild.setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY); 440 jobInfoBuild.setPersisted(true); 441 442 jobInfoBuild.setPeriodic( 443 adservicesPhFlags.getAppsearchDeletePeriodicIntervalMillis(), 444 adservicesPhFlags.getAppsearchDeleteJobFlexMillis()); 445 446 final JobInfo job = jobInfoBuild.build(); 447 448 jobScheduler.schedule(job); 449 Log.d(TAG, "Scheduled AdServicesAppsearchDeleteJobService with job id: " + JOB_ID); 450 } catch (Exception e) { 451 Log.e(TAG, "Exception in scheduling job AdServicesAppsearchDeleteJobService " + e); 452 } 453 } 454 455 } 456