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.ondevicepersonalization.libraries.plugin.internal; 18 19 import static java.util.concurrent.TimeUnit.MILLISECONDS; 20 21 import android.content.Context; 22 import android.content.pm.ApplicationInfo; 23 import android.content.pm.PackageInfo; 24 import android.content.pm.PackageManager.NameNotFoundException; 25 import android.content.res.AssetManager; 26 import android.os.FileUtils; 27 import android.os.Looper; 28 import android.os.ParcelFileDescriptor; 29 import android.os.RemoteException; 30 import android.util.Log; 31 import android.util.Pair; 32 33 import com.android.ondevicepersonalization.libraries.plugin.PluginInfo.ArchiveInfo; 34 35 import com.google.common.collect.ImmutableList; 36 import com.google.common.collect.Lists; 37 import com.google.common.io.CharStreams; 38 import com.google.common.io.Files; 39 import com.google.common.util.concurrent.SettableFuture; 40 41 import java.io.File; 42 import java.io.FileInputStream; 43 import java.io.FileOutputStream; 44 import java.io.IOException; 45 import java.io.InputStream; 46 import java.io.InputStreamReader; 47 import java.io.OutputStream; 48 import java.util.ArrayList; 49 import java.util.Collection; 50 import java.util.List; 51 import java.util.concurrent.ExecutionException; 52 import java.util.concurrent.TimeoutException; 53 54 /** 55 * Class responsible for managing Plugin archives in the application's cache directory, and 56 * forwarding file descriptors for them to a PluginTask. 57 */ 58 public final class PluginArchiveManager { 59 private static final String TAG = "PluginArchiveManager"; 60 private static final String CHECKSUM_SUFFIX = ".md5"; 61 private static final long BIND_TIMEOUT_MS = 2_000; 62 private final Context mApplicationContext; 63 PluginArchiveManager(Context applicationContext)64 public PluginArchiveManager(Context applicationContext) { 65 this.mApplicationContext = applicationContext; 66 } 67 68 /** Interface to wrap the service call the PluginManager wants to make. */ 69 @FunctionalInterface 70 @SuppressWarnings("AndroidApiChecker") 71 public interface PluginTask { 72 /** Executes the task specified in info. */ run(PluginInfoInternal info)73 void run(PluginInfoInternal info) throws RemoteException; 74 } 75 76 /** 77 * Create a File for the specified archive in the cache directory, together with the archive's 78 * checksum. 79 */ createArchiveFileInCacheDir(ArchiveInfo archive)80 private File createArchiveFileInCacheDir(ArchiveInfo archive) { 81 String filename; 82 if (archive.filename() != null) { 83 filename = archive.filename(); 84 } else { 85 filename = archive.packageName() + ".apk"; 86 } 87 return new File(mApplicationContext.getCacheDir(), filename); 88 } 89 90 /** 91 * Copy the passed in Plugin archives to the app's cache directory, open file descriptors for 92 * them, wait for the service to be ready, and perform a PluginTask. 93 */ copyPluginArchivesToCacheAndAwaitService( SettableFuture<Boolean> serviceReadiness, String serviceName, PluginInfoInternal.Builder infoBuilder, List<ArchiveInfo> pluginArchives, PluginTask pluginTask)94 public boolean copyPluginArchivesToCacheAndAwaitService( 95 SettableFuture<Boolean> serviceReadiness, 96 String serviceName, 97 PluginInfoInternal.Builder infoBuilder, 98 List<ArchiveInfo> pluginArchives, 99 PluginTask pluginTask) { 100 // Copy the plugin in app's assets to a file in app's cache directory then await 101 // readiness of the service before moving forward. 102 // This minimizes the amount of data/code within which the pluginClassName is looked up at 103 // sandbox container. 104 // Avoid using Context.getAssets().openFd() as it wraps a file descriptor mapped to 105 // the-entire-app-apk instead of the-plugin-archive-in-the-app-apk. 106 for (ArchiveInfo pluginArchive : pluginArchives) { 107 if (pluginArchive.packageName() != null && pluginArchive.filename() != null) { 108 // If the package is not null, and the file name is not null, the Plugin APK is an 109 // asset of 110 // the installed package. 111 if (!copyPluginFromPackageAssetsToCacheDir(pluginArchive)) { 112 return false; 113 } 114 } else if (pluginArchive.packageName() != null && pluginArchive.filename() == null) { 115 if (!copyPluginFromInstalledPackageToCacheDir(pluginArchive)) { 116 // If the package is not null, but the file name is null, the Plugin is the APK 117 // from the 118 // installed package. 119 return false; 120 } 121 } else if (pluginArchive.packageName() == null && pluginArchive.filename() != null) { 122 // If the package is null, and the filename is not null, the Plugin is an APK in the 123 // current 124 // application's assets. 125 if (!copyPluginFromAssetsToCacheDir(pluginArchive.filename())) { 126 return false; 127 } 128 } else { 129 Log.e(TAG, "Archive filename and package cannot both be null!"); 130 return false; 131 } 132 } 133 134 // Consider further optimizations and restrictions e.g., 135 // - Cache file descriptors (be careful of the shared file offset among all fd.dup()) 136 // - Restrict cpu affinity and usage i.e. background execution 137 138 ImmutableList<Pair<File, String>> archivesInCacheDir = 139 ImmutableList.copyOf( 140 Lists.transform( 141 pluginArchives, 142 (ArchiveInfo archive) -> 143 new Pair<>( 144 createArchiveFileInCacheDir(archive), 145 getArchiveChecksum(archive)))); 146 try (CloseableList<PluginCode> files = 147 createCloseablePluginCodeListFromFiles(archivesInCacheDir)) { 148 infoBuilder.setPluginCodeList(ImmutableList.copyOf(files.closeables())); 149 150 PluginInfoInternal info = infoBuilder.build(); 151 152 if (!maybeAwaitPluginServiceReady(serviceName, serviceReadiness)) { 153 return false; 154 } 155 pluginTask.run(info); 156 return true; 157 } catch (RemoteException e) { 158 Log.e( 159 TAG, 160 String.format( 161 "Error trying to call %s for the plugin: %s", 162 serviceName, pluginArchives)); 163 } catch (IOException e) { 164 Log.e(TAG, String.format("Error trying to load the plugin: %s", pluginArchives)); 165 } 166 return false; 167 } 168 createCloseablePluginCodeListFromFiles( Collection<Pair<File, String>> fileChecksumPairs)169 private static CloseableList<PluginCode> createCloseablePluginCodeListFromFiles( 170 Collection<Pair<File, String>> fileChecksumPairs) throws IOException { 171 List<PluginCode> fileList = new ArrayList<>(); 172 for (Pair<File, String> fileChecksumPair : fileChecksumPairs) { 173 File file = fileChecksumPair.first; 174 String checksum = fileChecksumPair.second; 175 fileList.add( 176 PluginCode.builder() 177 .setNativeFd( 178 ParcelFileDescriptor.open( 179 file, ParcelFileDescriptor.MODE_READ_ONLY)) 180 .setNonNativeFd( 181 ParcelFileDescriptor.open( 182 file, ParcelFileDescriptor.MODE_READ_ONLY)) 183 .setChecksum(checksum) 184 .build()); 185 } 186 187 return new CloseableList<>(fileList); 188 } 189 isMainThread()190 private static boolean isMainThread() { 191 return Looper.myLooper() == Looper.getMainLooper(); 192 } 193 maybeAwaitPluginServiceReady( String serviceName, SettableFuture<Boolean> readiness)194 private static boolean maybeAwaitPluginServiceReady( 195 String serviceName, SettableFuture<Boolean> readiness) { 196 try { 197 // Don't block-wait at app's main thread for service readiness as the readiness 198 // signal is asserted at onServiceConnected which also run at apps' main thread 199 // ends up deadlock or starvation since the signal will not be handled until the 200 // maybeAwaitPluginServiceReady finished. 201 if (isMainThread()) { 202 if (!readiness.isDone() || !readiness.get()) { 203 Log.w(TAG, String.format("%s is not ready yet", serviceName)); 204 return false; 205 } 206 } else { 207 return readiness.get(BIND_TIMEOUT_MS, MILLISECONDS); 208 } 209 } catch (InterruptedException | ExecutionException | TimeoutException e) { 210 Log.e(TAG, String.format("Error binding to %s", serviceName)); 211 return false; 212 } 213 return true; 214 } 215 216 /** 217 * When a checksum cannot be found, the empty string tells the executor to fall back to 218 * non-caching mode where preparation and setup for a plugin is executed from scratch. 219 */ 220 private static final String DEFAULT_CHECKSUM = ""; 221 getArchiveChecksum(ArchiveInfo pluginArchive)222 private String getArchiveChecksum(ArchiveInfo pluginArchive) { 223 AssetManager assetManager; 224 if (pluginArchive.packageName() != null) { 225 // TODO(b/247119575): resolve a mutant here. Test for cache hits & misses when expected. 226 if (pluginArchive.filename() == null) { 227 // TODO(b/248365642): return some other cacheKey, like lastUpdateTime, here. 228 return DEFAULT_CHECKSUM; 229 } 230 try { 231 assetManager = packageAssetManager(pluginArchive.packageName()); 232 } catch (NameNotFoundException e) { 233 Log.e(TAG, String.format("Unknown package name %s", pluginArchive.packageName())); 234 return DEFAULT_CHECKSUM; 235 } 236 } else { 237 assetManager = mApplicationContext.getAssets(); 238 } 239 240 String checksumFile = 241 Files.getNameWithoutExtension(pluginArchive.filename()) + CHECKSUM_SUFFIX; 242 try (InputStream checksumInAssets = assetManager.open(checksumFile); 243 InputStreamReader checksumInAssetsReader = 244 new InputStreamReader(checksumInAssets)) { 245 return CharStreams.toString(checksumInAssetsReader); 246 } catch (IOException e) { 247 return DEFAULT_CHECKSUM; 248 } 249 } 250 251 // TODO(b/247119575): Cover packageAssetManager() with unit tests. packageAssetManager(String pluginPackage)252 private AssetManager packageAssetManager(String pluginPackage) throws NameNotFoundException { 253 Context pluginContext = mApplicationContext.createPackageContext(pluginPackage, 0); 254 return pluginContext.getAssets(); 255 } 256 copyPluginFromInstalledPackageToCacheDir(ArchiveInfo pluginArchive)257 private boolean copyPluginFromInstalledPackageToCacheDir(ArchiveInfo pluginArchive) { 258 try { 259 PackageInfo packageInfo = 260 mApplicationContext 261 .getPackageManager() 262 .getPackageInfo(pluginArchive.packageName(), 0); 263 264 ApplicationInfo applicationInfo = packageInfo.applicationInfo; 265 if (applicationInfo == null) { 266 Log.e( 267 TAG, 268 String.format( 269 "Package %s has no ApplicationInfo", pluginArchive.packageName())); 270 return false; 271 } 272 273 String pluginApkPath = applicationInfo.sourceDir; 274 File pluginInCacheDir = createArchiveFileInCacheDir(pluginArchive); 275 276 try (InputStream pluginSrc = new FileInputStream(pluginApkPath); 277 OutputStream pluginDst = new FileOutputStream(pluginInCacheDir);) { 278 FileUtils.copy(pluginSrc, pluginDst); 279 return true; 280 } catch (IOException e) { 281 Log.e(TAG, String.format("Error copying %s to cache dir", pluginArchive)); 282 } 283 return false; 284 285 } catch (NameNotFoundException e) { 286 Log.e(TAG, String.format("Unknown package name %s", pluginArchive.packageName())); 287 } 288 return false; 289 } 290 copyPluginFromPackageAssetsToCacheDir(ArchiveInfo pluginArchive)291 private boolean copyPluginFromPackageAssetsToCacheDir(ArchiveInfo pluginArchive) { 292 try { 293 AssetManager assetManager = packageAssetManager(pluginArchive.packageName()); 294 return copyPluginToCacheDir(pluginArchive.filename(), assetManager); 295 } catch (NameNotFoundException e) { 296 Log.e(TAG, String.format("Unknown package name %s", pluginArchive.packageName())); 297 } 298 return false; 299 } 300 copyPluginFromAssetsToCacheDir(String pluginArchive)301 private boolean copyPluginFromAssetsToCacheDir(String pluginArchive) { 302 // Checksum filename should be in the format of <plugin_filename>.<CHECKSUM_SUFFIX>. 303 // E.g. plugin filename is foo.apk/foo.zip then checksum filename should be foo.md5 304 return copyPluginToCacheDir(pluginArchive, mApplicationContext.getAssets()); 305 } 306 307 // TODO(b/247119575): Cover copyPluginToCacheDir() with unit tests. 308 /** 309 * Copy the plugin to the cache directory, or reuse it, if it is already present with a matching 310 * checksum. Return true if the plugin has been copied or can be reused, return false if there 311 * is no reusable plugin in the cache directory and copying was unsuccessful. 312 */ copyPluginToCacheDir(String pluginArchive, AssetManager assetManager)313 private boolean copyPluginToCacheDir(String pluginArchive, AssetManager assetManager) { 314 // If pluginArchive has no file extension, append CHECKSUM_SUFFIX directly. 315 String checksumFile = Files.getNameWithoutExtension(pluginArchive) + CHECKSUM_SUFFIX; 316 if (canReusePluginInCacheDir(pluginArchive, checksumFile, assetManager)) { 317 return true; 318 } 319 320 File pluginInCacheDir = new File(mApplicationContext.getCacheDir(), pluginArchive); 321 File checksumInCacheDir = new File(mApplicationContext.getCacheDir(), checksumFile); 322 try (InputStream pluginSrc = assetManager.open(pluginArchive); 323 OutputStream pluginDst = new FileOutputStream(pluginInCacheDir); 324 InputStream checksumSrc = assetManager.open(checksumFile); 325 OutputStream checksumDst = new FileOutputStream(checksumInCacheDir)) { 326 // Data := content (plugin) + metadata (checksum) 327 // Enforce the Data writing order: (content -> metadata) like what common file 328 // systems do to ensure better fault tolerance and data integrity. 329 FileUtils.copy(pluginSrc, pluginDst); 330 FileUtils.copy(checksumSrc, checksumDst); 331 return true; 332 } catch (IOException e) { 333 Log.e( 334 TAG, 335 String.format("Error copying %s/%s to cache dir", pluginArchive, checksumFile)); 336 } 337 return false; 338 } 339 canReusePluginInCacheDir( String pluginArchive, String checksumFile, AssetManager assetManager)340 private boolean canReusePluginInCacheDir( 341 String pluginArchive, String checksumFile, AssetManager assetManager) { 342 // Can reuse the plugin at app's cache directory when both are met: 343 // - The plugin already existed at app's cache directory 344 // - Checksum of plugin_in_assets == Checksum of plugin_at_cache_dir 345 File pluginInCacheDir = new File(mApplicationContext.getCacheDir(), pluginArchive); 346 if (!pluginInCacheDir.exists()) { 347 return false; 348 } 349 try (InputStream checksumInAssets = assetManager.open(checksumFile); 350 InputStreamReader checksumInAssetsReader = new InputStreamReader(checksumInAssets); 351 InputStream checksumInCacheDir = 352 new FileInputStream( 353 new File(mApplicationContext.getCacheDir(), checksumFile)); 354 InputStreamReader checksumInCacheDirReader = 355 new InputStreamReader(checksumInCacheDir)) { 356 return CharStreams.toString(checksumInAssetsReader) 357 .equals(CharStreams.toString(checksumInCacheDirReader)); 358 } catch (IOException e) { 359 return false; 360 } 361 } 362 } 363