• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 // Copyright 2012 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4 
5 package org.chromium.base;
6 
7 import android.content.Context;
8 import android.content.SharedPreferences;
9 import android.content.pm.PackageInfo;
10 import android.content.pm.PackageManager;
11 import android.content.res.AssetManager;
12 import android.os.AsyncTask;
13 import android.preference.PreferenceManager;
14 import android.util.Log;
15 
16 import java.io.File;
17 import java.io.FileOutputStream;
18 import java.io.FilenameFilter;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.io.OutputStream;
22 import java.util.ArrayList;
23 import java.util.HashSet;
24 import java.util.List;
25 import java.util.concurrent.CancellationException;
26 import java.util.concurrent.ExecutionException;
27 import java.util.regex.Pattern;
28 
29 /**
30  * Handles extracting the necessary resources bundled in an APK and moving them to a location on
31  * the file system accessible from the native code.
32  */
33 public class ResourceExtractor {
34 
35     private static final String LOGTAG = "ResourceExtractor";
36     private static final String LAST_LANGUAGE = "Last language";
37     private static final String PAK_FILENAMES = "Pak filenames";
38     private static final String ICU_DATA_FILENAME = "icudtl.dat";
39 
40     private static String[] sMandatoryPaks = null;
41 
42     // By default, we attempt to extract a pak file for the users
43     // current device locale. Use setExtractImplicitLocale() to
44     // change this behavior.
45     private static boolean sExtractImplicitLocalePak = true;
46 
47     private class ExtractTask extends AsyncTask<Void, Void, Void> {
48         private static final int BUFFER_SIZE = 16 * 1024;
49 
ExtractTask()50         public ExtractTask() {
51         }
52 
53         @Override
doInBackground(Void... unused)54         protected Void doInBackground(Void... unused) {
55             final File outputDir = getOutputDir();
56             if (!outputDir.exists() && !outputDir.mkdirs()) {
57                 Log.e(LOGTAG, "Unable to create pak resources directory!");
58                 return null;
59             }
60 
61             String timestampFile = checkPakTimestamp(outputDir);
62             if (timestampFile != null) {
63                 deleteFiles();
64             }
65 
66             SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext);
67             HashSet<String> filenames = (HashSet<String>) prefs.getStringSet(
68                     PAK_FILENAMES, new HashSet<String>());
69             String currentLocale = LocaleUtils.getDefaultLocale();
70             String currentLanguage = currentLocale.split("-", 2)[0];
71 
72             if (prefs.getString(LAST_LANGUAGE, "").equals(currentLanguage)
73                     &&  filenames.size() >= sMandatoryPaks.length) {
74                 boolean filesPresent = true;
75                 for (String file : filenames) {
76                     if (!new File(outputDir, file).exists()) {
77                         filesPresent = false;
78                         break;
79                     }
80                 }
81                 if (filesPresent) return null;
82             } else {
83                 prefs.edit().putString(LAST_LANGUAGE, currentLanguage).apply();
84             }
85 
86             StringBuilder p = new StringBuilder();
87             for (String mandatoryPak : sMandatoryPaks) {
88                 if (p.length() > 0) p.append('|');
89                 p.append("\\Q" + mandatoryPak + "\\E");
90             }
91 
92             if (sExtractImplicitLocalePak) {
93                 if (p.length() > 0) p.append('|');
94                 // As well as the minimum required set of .paks above, we'll also add all .paks that
95                 // we have for the user's currently selected language.
96 
97                 p.append(currentLanguage);
98                 p.append("(-\\w+)?\\.pak");
99             }
100 
101             Pattern paksToInstall = Pattern.compile(p.toString());
102 
103             AssetManager manager = mContext.getResources().getAssets();
104             try {
105                 // Loop through every asset file that we have in the APK, and look for the
106                 // ones that we need to extract by trying to match the Patterns that we
107                 // created above.
108                 byte[] buffer = null;
109                 String[] files = manager.list("");
110                 for (String file : files) {
111                     if (!paksToInstall.matcher(file).matches()) {
112                         continue;
113                     }
114                     boolean isICUData = file.equals(ICU_DATA_FILENAME);
115                     File output = new File(isICUData ? getAppDataDir() : outputDir, file);
116                     if (output.exists()) {
117                         continue;
118                     }
119 
120                     InputStream is = null;
121                     OutputStream os = null;
122                     try {
123                         is = manager.open(file);
124                         os = new FileOutputStream(output);
125                         Log.i(LOGTAG, "Extracting resource " + file);
126                         if (buffer == null) {
127                             buffer = new byte[BUFFER_SIZE];
128                         }
129 
130                         int count = 0;
131                         while ((count = is.read(buffer, 0, BUFFER_SIZE)) != -1) {
132                             os.write(buffer, 0, count);
133                         }
134                         os.flush();
135 
136                         // Ensure something reasonable was written.
137                         if (output.length() == 0) {
138                             throw new IOException(file + " extracted with 0 length!");
139                         }
140 
141                         if (!isICUData) {
142                             filenames.add(file);
143                         } else {
144                             // icudata needs to be accessed by a renderer process.
145                             output.setReadable(true, false);
146                         }
147                     } finally {
148                         try {
149                             if (is != null) {
150                                 is.close();
151                             }
152                         } finally {
153                             if (os != null) {
154                                 os.close();
155                             }
156                         }
157                     }
158                 }
159             } catch (IOException e) {
160                 // TODO(benm): See crbug/152413.
161                 // Try to recover here, can we try again after deleting files instead of
162                 // returning null? It might be useful to gather UMA here too to track if
163                 // this happens with regularity.
164                 Log.w(LOGTAG, "Exception unpacking required pak resources: " + e.getMessage());
165                 deleteFiles();
166                 return null;
167             }
168 
169             // Finished, write out a timestamp file if we need to.
170 
171             if (timestampFile != null) {
172                 try {
173                     new File(outputDir, timestampFile).createNewFile();
174                 } catch (IOException e) {
175                     // Worst case we don't write a timestamp, so we'll re-extract the resource
176                     // paks next start up.
177                     Log.w(LOGTAG, "Failed to write resource pak timestamp!");
178                 }
179             }
180             // TODO(yusufo): Figure out why remove is required here.
181             prefs.edit().remove(PAK_FILENAMES).apply();
182             prefs.edit().putStringSet(PAK_FILENAMES, filenames).apply();
183             return null;
184         }
185 
186         // Looks for a timestamp file on disk that indicates the version of the APK that
187         // the resource paks were extracted from. Returns null if a timestamp was found
188         // and it indicates that the resources match the current APK. Otherwise returns
189         // a String that represents the filename of a timestamp to create.
190         // Note that we do this to avoid adding a BroadcastReceiver on
191         // android.content.Intent#ACTION_PACKAGE_CHANGED as that causes process churn
192         // on (re)installation of *all* APK files.
checkPakTimestamp(File outputDir)193         private String checkPakTimestamp(File outputDir) {
194             final String timestampPrefix = "pak_timestamp-";
195             PackageManager pm = mContext.getPackageManager();
196             PackageInfo pi = null;
197 
198             try {
199                 pi = pm.getPackageInfo(mContext.getPackageName(), 0);
200             } catch (PackageManager.NameNotFoundException e) {
201                 return timestampPrefix;
202             }
203 
204             if (pi == null) {
205                 return timestampPrefix;
206             }
207 
208             String expectedTimestamp = timestampPrefix + pi.versionCode + "-" + pi.lastUpdateTime;
209 
210             String[] timestamps = outputDir.list(new FilenameFilter() {
211                 @Override
212                 public boolean accept(File dir, String name) {
213                     return name.startsWith(timestampPrefix);
214                 }
215             });
216 
217             if (timestamps.length != 1) {
218                 // If there's no timestamp, nuke to be safe as we can't tell the age of the files.
219                 // If there's multiple timestamps, something's gone wrong so nuke.
220                 return expectedTimestamp;
221             }
222 
223             if (!expectedTimestamp.equals(timestamps[0])) {
224                 return expectedTimestamp;
225             }
226 
227             // timestamp file is already up-to date.
228             return null;
229         }
230     }
231 
232     private final Context mContext;
233     private ExtractTask mExtractTask;
234 
235     private static ResourceExtractor sInstance;
236 
get(Context context)237     public static ResourceExtractor get(Context context) {
238         if (sInstance == null) {
239             sInstance = new ResourceExtractor(context);
240         }
241         return sInstance;
242     }
243 
244     /**
245      * Specifies the .pak files that should be extracted from the APK's asset resources directory
246      * and moved to {@link #getOutputDirFromContext(Context)}.
247      * @param mandatoryPaks The list of pak files to be loaded. If no pak files are
248      *     required, pass a single empty string.
249      */
setMandatoryPaksToExtract(String... mandatoryPaks)250     public static void setMandatoryPaksToExtract(String... mandatoryPaks) {
251         assert (sInstance == null || sInstance.mExtractTask == null)
252                 : "Must be called before startExtractingResources is called";
253         sMandatoryPaks = mandatoryPaks;
254 
255     }
256 
257     /**
258      * By default the ResourceExtractor will attempt to extract a pak resource for the users
259      * currently specified locale. This behavior can be changed with this function and is
260      * only needed by tests.
261      * @param extract False if we should not attempt to extract a pak file for
262      *         the users currently selected locale and try to extract only the
263      *         pak files specified in sMandatoryPaks.
264      */
265     @VisibleForTesting
setExtractImplicitLocaleForTesting(boolean extract)266     public static void setExtractImplicitLocaleForTesting(boolean extract) {
267         assert (sInstance == null || sInstance.mExtractTask == null)
268                 : "Must be called before startExtractingResources is called";
269         sExtractImplicitLocalePak = extract;
270     }
271 
272     /**
273      * Marks all the 'pak' resources, packaged as assets, for extraction during
274      * running the tests.
275      */
276     @VisibleForTesting
setExtractAllPaksForTesting()277     public void setExtractAllPaksForTesting() {
278         List<String> pakFileAssets = new ArrayList<String>();
279         AssetManager manager = mContext.getResources().getAssets();
280         try {
281             String[] files = manager.list("");
282             for (String file : files) {
283                 if (file.endsWith(".pak")) pakFileAssets.add(file);
284             }
285         } catch (IOException e) {
286             Log.w(LOGTAG, "Exception while accessing assets: " + e.getMessage(), e);
287         }
288         setMandatoryPaksToExtract(pakFileAssets.toArray(new String[pakFileAssets.size()]));
289     }
290 
ResourceExtractor(Context context)291     private ResourceExtractor(Context context) {
292         mContext = context.getApplicationContext();
293     }
294 
waitForCompletion()295     public void waitForCompletion() {
296         if (shouldSkipPakExtraction()) {
297             return;
298         }
299 
300         assert mExtractTask != null;
301 
302         try {
303             mExtractTask.get();
304         } catch (CancellationException e) {
305             // Don't leave the files in an inconsistent state.
306             deleteFiles();
307         } catch (ExecutionException e2) {
308             deleteFiles();
309         } catch (InterruptedException e3) {
310             deleteFiles();
311         }
312     }
313 
314     /**
315      * This will extract the application pak resources in an
316      * AsyncTask. Call waitForCompletion() at the point resources
317      * are needed to block until the task completes.
318      */
startExtractingResources()319     public void startExtractingResources() {
320         if (mExtractTask != null) {
321             return;
322         }
323 
324         if (shouldSkipPakExtraction()) {
325             return;
326         }
327 
328         mExtractTask = new ExtractTask();
329         mExtractTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
330     }
331 
getAppDataDir()332     private File getAppDataDir() {
333         return new File(PathUtils.getDataDirectory(mContext));
334     }
335 
getOutputDir()336     private File getOutputDir() {
337         return new File(getAppDataDir(), "paks");
338     }
339 
340     /**
341      * Pak files (UI strings and other resources) should be updated along with
342      * Chrome. A version mismatch can lead to a rather broken user experience.
343      * The ICU data (icudtl.dat) is less version-sensitive, but still can
344      * lead to malfunction/UX misbehavior. So, we regard failing to update them
345      * as an error.
346      */
deleteFiles()347     private void deleteFiles() {
348         File icudata = new File(getAppDataDir(), ICU_DATA_FILENAME);
349         if (icudata.exists() && !icudata.delete()) {
350             Log.e(LOGTAG, "Unable to remove the icudata " + icudata.getName());
351         }
352         File dir = getOutputDir();
353         if (dir.exists()) {
354             File[] files = dir.listFiles();
355             for (File file : files) {
356                 if (!file.delete()) {
357                     Log.e(LOGTAG, "Unable to remove existing resource " + file.getName());
358                 }
359             }
360         }
361     }
362 
363     /**
364      * Pak extraction not necessarily required by the embedder; we allow them to skip
365      * this process if they call setMandatoryPaksToExtract with a single empty String.
366      */
shouldSkipPakExtraction()367     private static boolean shouldSkipPakExtraction() {
368         // Must call setMandatoryPaksToExtract before beginning resource extraction.
369         assert sMandatoryPaks != null;
370         return sMandatoryPaks.length == 1 && "".equals(sMandatoryPaks[0]);
371     }
372 }
373