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 com.android.server.healthconnect.exportimport; 18 19 import static com.android.healthfitness.flags.Flags.exportImportFastFollow; 20 import static com.android.healthfitness.flags.Flags.extendExportImportTelemetry; 21 22 import android.app.job.JobInfo; 23 import android.app.job.JobScheduler; 24 import android.content.ComponentName; 25 import android.content.Context; 26 import android.os.PersistableBundle; 27 import android.os.UserHandle; 28 import android.util.Slog; 29 30 import com.android.internal.annotations.VisibleForTesting; 31 import com.android.server.healthconnect.HealthConnectDailyService; 32 import com.android.server.healthconnect.storage.ExportImportSettingsStorage; 33 34 import java.time.Duration; 35 import java.util.Objects; 36 37 /** 38 * Defines jobs related to Health Connect export and imports. 39 * 40 * @hide 41 */ 42 public class ExportImportJobs { 43 private static final String TAG = "HealthConnectExportJobs"; 44 private static final int MIN_JOB_ID = ExportImportJobs.class.hashCode(); 45 46 @VisibleForTesting static final String IS_FIRST_EXPORT = "is_first_export"; 47 @VisibleForTesting static final String NAMESPACE = "HEALTH_CONNECT_IMPORT_EXPORT_JOBS"; 48 49 public static final String PERIODIC_EXPORT_JOB_NAME = "periodic_export_job"; 50 51 /** 52 * Checks if the rescheduling is needed and schedules the periodic export job if so. 53 * 54 * <p>Needed in particular for device restarts and user switches. The job is persisted in those 55 * cases and we want to avoid rescheduling, because otherwise, if a user restarts their phone 56 * often, the job may never complete. 57 */ schedulePeriodicJobIfNotScheduled( UserHandle userHandle, Context context, ExportImportSettingsStorage exportImportSettingsStorage, ExportManager exportManager)58 public static void schedulePeriodicJobIfNotScheduled( 59 UserHandle userHandle, 60 Context context, 61 ExportImportSettingsStorage exportImportSettingsStorage, 62 ExportManager exportManager) { 63 if (!exportImportFastFollow() 64 || Objects.requireNonNull(context.getSystemService(JobScheduler.class)) 65 .forNamespace(NAMESPACE) 66 .getAllPendingJobs() 67 .isEmpty()) { 68 schedulePeriodicExportJob( 69 userHandle, context, exportImportSettingsStorage, exportManager); 70 } 71 } 72 73 /** Schedule the periodic export job. */ schedulePeriodicExportJob( UserHandle userHandle, Context context, ExportImportSettingsStorage exportImportSettingsStorage, ExportManager exportManager)74 public static void schedulePeriodicExportJob( 75 UserHandle userHandle, 76 Context context, 77 ExportImportSettingsStorage exportImportSettingsStorage, 78 ExportManager exportManager) { 79 int periodInDays = exportImportSettingsStorage.getScheduledExportPeriodInDays(); 80 81 if (extendExportImportTelemetry()) { 82 // A new export is set up, reset the retry count for logging 83 exportImportSettingsStorage.resetExportRepeatErrorOnRetryCount(); 84 } 85 86 if (exportImportFastFollow()) { 87 // We should always cancel the job as we are persisting the job now. 88 Objects.requireNonNull(context.getSystemService(JobScheduler.class)) 89 .forNamespace(NAMESPACE) 90 .cancelAll(); 91 92 // TODO(b/364855153): Move to next condition once fast follow flag is enabled. 93 // If export is off we try to delete the local files, just in case it happened the 94 // rare case where those files weren't deleted after the last export. 95 if (periodInDays <= 0) { 96 exportManager.deleteLocalExportFiles(userHandle); 97 } 98 } 99 // If period is 0 the user has turned export off, we should no longer schedule a new job 100 if (periodInDays <= 0) { 101 return; 102 } 103 104 PersistableBundle extras = new PersistableBundle(); 105 extras.putInt(HealthConnectDailyService.EXTRA_USER_ID, userHandle.getIdentifier()); 106 extras.putString(HealthConnectDailyService.EXTRA_JOB_NAME_KEY, PERIODIC_EXPORT_JOB_NAME); 107 108 long periodInMillis = Duration.ofDays(periodInDays).toMillis(); 109 long flexInMillis = Duration.ofHours(6).toMillis(); 110 if (periodInDays >= 7) { 111 // For weekly / monthly export, allow export to happen at least one day before. 112 flexInMillis = Duration.ofDays(1).toMillis(); 113 } 114 if (exportImportSettingsStorage.getLastSuccessfulExportTime() == null 115 || !exportImportSettingsStorage 116 .getUri() 117 .equals(exportImportSettingsStorage.getLastSuccessfulExportUri())) { 118 119 // Shorten the period for the first export to any new URI. 120 // This will be changed back once the first export to any new location is done. 121 periodInMillis = Duration.ofHours(1).toMillis(); 122 flexInMillis = periodInMillis; 123 124 extras.putBoolean(IS_FIRST_EXPORT, true); 125 } 126 Slog.i( 127 TAG, 128 "Scheduling export job with period (millis) = " 129 + periodInMillis 130 + " flex = " 131 + flexInMillis); 132 133 ComponentName componentName = new ComponentName(context, HealthConnectDailyService.class); 134 JobInfo.Builder builder = 135 new JobInfo.Builder(MIN_JOB_ID + userHandle.getIdentifier(), componentName) 136 .setRequiresCharging(true) 137 .setRequiresDeviceIdle(true) 138 .setPeriodic( 139 periodInMillis, 140 // Flex interval. 141 // The flex period begins after (periodInMillis - flexInMillis) 142 // Flex is the max of the specified time, or 5% of periodInMillis. 143 flexInMillis) 144 .setExtras(extras); 145 if (exportImportFastFollow()) { 146 // Persist the job to avoid rescheduling when restarting the device. 147 // Otherwise if the user repeatedly restarts their phone, an export may never happen. 148 builder = builder.setPersisted(true); 149 } 150 151 HealthConnectDailyService.schedule( 152 Objects.requireNonNull(context.getSystemService(JobScheduler.class)) 153 .forNamespace(NAMESPACE), 154 userHandle, 155 builder.build()); 156 } 157 158 /** 159 * Execute the periodic export job. It returns true if the export was successful (no need to 160 * reschedule the job). False otherwise. 161 */ 162 // TODO(b/318484778): Use dependency injection instead of passing an instance to the method. executePeriodicExportJob( Context context, UserHandle userHandle, PersistableBundle extras, ExportManager exportManager, ExportImportSettingsStorage exportImportSettingsStorage)163 public static boolean executePeriodicExportJob( 164 Context context, 165 UserHandle userHandle, 166 PersistableBundle extras, 167 ExportManager exportManager, 168 ExportImportSettingsStorage exportImportSettingsStorage) { 169 if (exportImportSettingsStorage.getScheduledExportPeriodInDays() <= 0) { 170 // If there is no need to run the export, it counts like a success regarding job 171 // reschedule. 172 return true; 173 } 174 175 boolean exportSuccess = exportManager.runExport(userHandle); 176 boolean firstExport = extras.getBoolean(IS_FIRST_EXPORT, false); 177 if (exportSuccess && firstExport) { 178 schedulePeriodicExportJob( 179 userHandle, context, exportImportSettingsStorage, exportManager); 180 } 181 return exportSuccess; 182 183 // TODO(b/325599089): Do we need an additional periodic / one-off task to make sure a single 184 // export completes? We need to test if JobScheduler will call the job again if jobFinished 185 // is never called. 186 187 // TODO(b/325599089): Consider if we need to do any checkpointing here in case the job 188 // doesn't complete and we need to pick it up again. 189 } 190 } 191