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