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