1 /* 2 * Copyright (C) 2018 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.textclassifier.downloader; 18 19 import static com.android.textclassifier.downloader.TextClassifierDownloadLogger.REASON_TO_SCHEDULE_DEVICE_CONFIG_UPDATED; 20 import static com.android.textclassifier.downloader.TextClassifierDownloadLogger.REASON_TO_SCHEDULE_LOCALE_SETTINGS_CHANGED; 21 import static com.android.textclassifier.downloader.TextClassifierDownloadLogger.REASON_TO_SCHEDULE_TCS_STARTED; 22 import static java.util.concurrent.TimeUnit.MILLISECONDS; 23 24 import android.content.BroadcastReceiver; 25 import android.content.Context; 26 import android.content.Intent; 27 import android.content.IntentFilter; 28 import android.os.LocaleList; 29 import android.provider.DeviceConfig; 30 import android.text.TextUtils; 31 import androidx.work.BackoffPolicy; 32 import androidx.work.Constraints; 33 import androidx.work.Data; 34 import androidx.work.ExistingWorkPolicy; 35 import androidx.work.ListenableWorker; 36 import androidx.work.NetworkType; 37 import androidx.work.OneTimeWorkRequest; 38 import androidx.work.Operation; 39 import androidx.work.WorkManager; 40 import com.android.textclassifier.common.ModelType.ModelTypeDef; 41 import com.android.textclassifier.common.TextClassifierSettings; 42 import com.android.textclassifier.common.base.TcLog; 43 import com.android.textclassifier.utils.IndentingPrintWriter; 44 import com.google.common.annotations.VisibleForTesting; 45 import com.google.common.base.Enums; 46 import com.google.common.base.Preconditions; 47 import com.google.common.collect.ImmutableList; 48 import com.google.common.hash.Hashing; 49 import com.google.common.util.concurrent.FutureCallback; 50 import com.google.common.util.concurrent.Futures; 51 import com.google.common.util.concurrent.ListenableFuture; 52 import com.google.common.util.concurrent.ListeningExecutorService; 53 import java.io.File; 54 import java.time.Instant; 55 import java.util.List; 56 import java.util.Locale; 57 import java.util.UUID; 58 import java.util.concurrent.Callable; 59 import javax.annotation.Nullable; 60 61 /** Manager to listen to config update and download latest models. */ 62 public final class ModelDownloadManager { 63 private static final String TAG = "ModelDownloadManager"; 64 65 @VisibleForTesting static final String UNIQUE_QUEUE_NAME = "ModelDownloadWorkManagerQueue"; 66 67 private final Context appContext; 68 private final Class<? extends ListenableWorker> modelDownloadWorkerClass; 69 private final Callable<WorkManager> workManagerSupplier; 70 private final DownloadedModelManager downloadedModelManager; 71 private final TextClassifierSettings settings; 72 private final ListeningExecutorService executorService; 73 private final DeviceConfig.OnPropertiesChangedListener deviceConfigListener; 74 private final BroadcastReceiver localeChangedReceiver; 75 76 /** 77 * Constructor for ModelDownloadManager. 78 * 79 * @param appContext the context of this application 80 * @param settings TextClassifierSettings to access DeviceConfig and other settings 81 * @param executorService background executor service 82 */ ModelDownloadManager( Context appContext, TextClassifierSettings settings, ListeningExecutorService executorService)83 public ModelDownloadManager( 84 Context appContext, 85 TextClassifierSettings settings, 86 ListeningExecutorService executorService) { 87 this( 88 appContext, 89 ModelDownloadWorker.class, 90 () -> WorkManager.getInstance(appContext), 91 DownloadedModelManagerImpl.getInstance(appContext), 92 settings, 93 executorService); 94 } 95 96 @VisibleForTesting ModelDownloadManager( Context appContext, Class<? extends ListenableWorker> modelDownloadWorkerClass, Callable<WorkManager> workManagerSupplier, DownloadedModelManager downloadedModelManager, TextClassifierSettings settings, ListeningExecutorService executorService)97 public ModelDownloadManager( 98 Context appContext, 99 Class<? extends ListenableWorker> modelDownloadWorkerClass, 100 Callable<WorkManager> workManagerSupplier, 101 DownloadedModelManager downloadedModelManager, 102 TextClassifierSettings settings, 103 ListeningExecutorService executorService) { 104 this.appContext = Preconditions.checkNotNull(appContext); 105 this.modelDownloadWorkerClass = Preconditions.checkNotNull(modelDownloadWorkerClass); 106 this.workManagerSupplier = Preconditions.checkNotNull(workManagerSupplier); 107 this.downloadedModelManager = Preconditions.checkNotNull(downloadedModelManager); 108 this.settings = Preconditions.checkNotNull(settings); 109 this.executorService = Preconditions.checkNotNull(executorService); 110 111 this.deviceConfigListener = 112 new DeviceConfig.OnPropertiesChangedListener() { 113 @Override 114 public void onPropertiesChanged(DeviceConfig.Properties unused) { 115 onTextClassifierDeviceConfigChanged(); 116 } 117 }; 118 this.localeChangedReceiver = 119 new BroadcastReceiver() { 120 @Override 121 public void onReceive(Context context, Intent intent) { 122 onLocaleChanged(); 123 } 124 }; 125 } 126 127 /** Returns the downlaoded models for the given modelType. */ 128 @Nullable listDownloadedModels(@odelTypeDef String modelType)129 public List<File> listDownloadedModels(@ModelTypeDef String modelType) { 130 try { 131 return downloadedModelManager.listModels(modelType); 132 } catch (Throwable t) { 133 TcLog.e(TAG, "Failed to list downloaded models", t); 134 return ImmutableList.of(); 135 } 136 } 137 138 /** Notifies the model downlaoder that the text classifier service is created. */ onTextClassifierServiceCreated()139 public void onTextClassifierServiceCreated() { 140 try { 141 DeviceConfig.addOnPropertiesChangedListener( 142 DeviceConfig.NAMESPACE_TEXTCLASSIFIER, executorService, deviceConfigListener); 143 appContext.registerReceiver( 144 localeChangedReceiver, new IntentFilter(Intent.ACTION_LOCALE_CHANGED)); 145 TcLog.d(TAG, "DeviceConfig listener and locale change listener are registered."); 146 if (!settings.isModelDownloadManagerEnabled()) { 147 return; 148 } 149 maybeOverrideLocaleListForTesting(); 150 TcLog.d(TAG, "Try to schedule model download work because TextClassifierService started."); 151 scheduleDownloadWork(REASON_TO_SCHEDULE_TCS_STARTED); 152 } catch (Throwable t) { 153 TcLog.e(TAG, "Failed inside onTextClassifierServiceCreated", t); 154 } 155 } 156 157 // TODO(licha): Make this private. Let the constructor accept a receiver to enable testing. 158 /** Notifies the model downlaoder that the system locale setting is changed. */ 159 @VisibleForTesting onLocaleChanged()160 void onLocaleChanged() { 161 if (!settings.isModelDownloadManagerEnabled()) { 162 return; 163 } 164 TcLog.d(TAG, "Try to schedule model download work because of system locale changes."); 165 try { 166 scheduleDownloadWork(REASON_TO_SCHEDULE_LOCALE_SETTINGS_CHANGED); 167 } catch (Throwable t) { 168 TcLog.e(TAG, "Failed inside onLocaleChanged", t); 169 } 170 } 171 172 // TODO(licha): Make this private. Let the constructor accept a receiver to enable testing. 173 /** Notifies the model downlaoder that the device config for textclassifier is changed. */ 174 @VisibleForTesting onTextClassifierDeviceConfigChanged()175 void onTextClassifierDeviceConfigChanged() { 176 if (!settings.isModelDownloadManagerEnabled()) { 177 return; 178 } 179 TcLog.d(TAG, "Try to schedule model download work because of device config changes."); 180 try { 181 maybeOverrideLocaleListForTesting(); 182 scheduleDownloadWork(REASON_TO_SCHEDULE_DEVICE_CONFIG_UPDATED); 183 } catch (Throwable t) { 184 TcLog.e(TAG, "Failed inside onTextClassifierDeviceConfigChanged", t); 185 } 186 } 187 188 /** Clean up internal states on destroying. */ destroy()189 public void destroy() { 190 try { 191 DeviceConfig.removeOnPropertiesChangedListener(deviceConfigListener); 192 appContext.unregisterReceiver(localeChangedReceiver); 193 TcLog.d(TAG, "DeviceConfig and Locale listener unregistered by ModelDownloadeManager"); 194 } catch (Throwable t) { 195 TcLog.e(TAG, "Failed to destroy ModelDownloadManager", t); 196 } 197 } 198 199 /** 200 * Dumps the internal state for debugging. 201 * 202 * @param printWriter writer to write dumped states 203 */ dump(IndentingPrintWriter printWriter)204 public void dump(IndentingPrintWriter printWriter) { 205 if (!settings.isModelDownloadManagerEnabled()) { 206 return; 207 } 208 try { 209 printWriter.println("ModelDownloadManager:"); 210 printWriter.increaseIndent(); 211 downloadedModelManager.dump(printWriter); 212 printWriter.decreaseIndent(); 213 } catch (Throwable t) { 214 TcLog.e(TAG, "Failed to dump ModelDownloadManager", t); 215 } 216 } 217 218 /** 219 * Enqueue an idempotent work to check device configs and download model files if necessary. 220 * 221 * <p>At any time there will only be at most one work running. If a work is already pending or 222 * running, the newly scheduled work will be appended as a child of that work. 223 */ scheduleDownloadWork(int reasonToSchedule)224 private void scheduleDownloadWork(int reasonToSchedule) { 225 long workId = 226 Hashing.farmHashFingerprint64().hashUnencodedChars(UUID.randomUUID().toString()).asLong(); 227 try { 228 NetworkType networkType = 229 Enums.getIfPresent(NetworkType.class, settings.getManifestDownloadRequiredNetworkType()) 230 .or(NetworkType.UNMETERED); 231 OneTimeWorkRequest downloadRequest = 232 new OneTimeWorkRequest.Builder(modelDownloadWorkerClass) 233 .setConstraints( 234 new Constraints.Builder() 235 .setRequiredNetworkType(networkType) 236 .setRequiresBatteryNotLow(true) 237 .setRequiresStorageNotLow(true) 238 .setRequiresDeviceIdle(settings.getManifestDownloadRequiresDeviceIdle()) 239 .setRequiresCharging(settings.getManifestDownloadRequiresCharging()) 240 .build()) 241 .setBackoffCriteria( 242 BackoffPolicy.EXPONENTIAL, 243 settings.getModelDownloadBackoffDelayInMillis(), 244 MILLISECONDS) 245 .setInputData( 246 new Data.Builder() 247 .putLong(ModelDownloadWorker.INPUT_DATA_KEY_WORK_ID, workId) 248 .putLong( 249 ModelDownloadWorker.INPUT_DATA_KEY_SCHEDULED_TIMESTAMP, 250 Instant.now().toEpochMilli()) 251 .build()) 252 .build(); 253 ListenableFuture<Operation.State.SUCCESS> enqueueResultFuture = 254 workManagerSupplier 255 .call() 256 .enqueueUniqueWork( 257 UNIQUE_QUEUE_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, downloadRequest) 258 .getResult(); 259 Futures.addCallback( 260 enqueueResultFuture, 261 new FutureCallback<Operation.State.SUCCESS>() { 262 @Override 263 public void onSuccess(Operation.State.SUCCESS unused) { 264 TcLog.d(TAG, "Download work scheduled."); 265 TextClassifierDownloadLogger.downloadWorkScheduled( 266 workId, reasonToSchedule, /* failedToSchedule= */ false); 267 } 268 269 @Override 270 public void onFailure(Throwable t) { 271 TcLog.e(TAG, "Failed to schedule download work: ", t); 272 TextClassifierDownloadLogger.downloadWorkScheduled( 273 workId, reasonToSchedule, /* failedToSchedule= */ true); 274 } 275 }, 276 executorService); 277 } catch (Throwable t) { 278 // TODO(licha): this is just for temporary fix. Refactor the try-catch in the future. 279 TcLog.e(TAG, "Failed to schedule download work: ", t); 280 TextClassifierDownloadLogger.downloadWorkScheduled( 281 workId, reasonToSchedule, /* failedToSchedule= */ true); 282 } 283 } 284 maybeOverrideLocaleListForTesting()285 private void maybeOverrideLocaleListForTesting() { 286 String localeList = settings.getTestingLocaleListOverride(); 287 if (TextUtils.isEmpty(localeList)) { 288 return; 289 } 290 TcLog.d( 291 TAG, 292 String.format( 293 Locale.US, 294 "Override LocaleList from %s to %s", 295 LocaleList.getAdjustedDefault().toLanguageTags(), 296 localeList)); 297 LocaleList.setDefault(LocaleList.forLanguageTags(localeList)); 298 } 299 } 300