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