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.
5 package org.chromium.base;
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;
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;
25 import java.util.List;
26 import java.util.concurrent.CancellationException;
27 import java.util.concurrent.ExecutionException;
28 import java.util.regex.Pattern;
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.
34 public class ResourceExtractor {
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";
41 private static String[] sMandatoryPaks = null;
42 private static ResourceIntercepter sIntercepter = null;
44 // By default, we attempt to extract a pak file for the users
45 // current device locale. Use setExtractImplicitLocale() to
46 // change this behavior.
47 private static boolean sExtractImplicitLocalePak = true;
49 public interface ResourceIntercepter {
50 Set<String> getInterceptableResourceList();
51 InputStream interceptLoadingForResource(String resource);
54 private class ExtractTask extends AsyncTask<Void, Void, Void> {
55 private static final int BUFFER_SIZE = 16 * 1024;
57 public ExtractTask() {
61 protected Void doInBackground(Void... unused) {
62 final File outputDir = getOutputDir();
63 if (!outputDir.exists() && !outputDir.mkdirs()) {
64 Log.e(LOGTAG, "Unable to create pak resources directory!");
68 String timestampFile = checkPakTimestamp(outputDir);
69 if (timestampFile != null) {
73 SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
74 mContext.getApplicationContext());
75 HashSet<String> filenames = (HashSet<String>) prefs.getStringSet(
76 PAK_FILENAMES, new HashSet<String>());
77 String currentLocale = LocaleUtils.getDefaultLocale();
78 String currentLanguage = currentLocale.split("-", 2)[0];
80 if (prefs.getString(LAST_LANGUAGE, "").equals(currentLanguage)
81 && filenames.size() >= sMandatoryPaks.length) {
82 boolean filesPresent = true;
83 for (String file : filenames) {
84 if (!new File(outputDir, file).exists()) {
89 if (filesPresent) return null;
91 prefs.edit().putString(LAST_LANGUAGE, currentLanguage).apply();
94 StringBuilder p = new StringBuilder();
95 for (String mandatoryPak : sMandatoryPaks) {
96 if (p.length() > 0) p.append('|');
97 p.append("\\Q" + mandatoryPak + "\\E");
100 if (sExtractImplicitLocalePak) {
101 if (p.length() > 0) p.append('|');
102 // As well as the minimum required set of .paks above, we'll also add all .paks that
103 // we have for the user's currently selected language.
105 p.append(currentLanguage);
106 p.append("(-\\w+)?\\.pak");
109 Pattern paksToInstall = Pattern.compile(p.toString());
111 AssetManager manager = mContext.getResources().getAssets();
113 // Loop through every asset file that we have in the APK, and look for the
114 // ones that we need to extract by trying to match the Patterns that we
116 byte[] buffer = null;
117 String[] files = manager.list("");
118 if (sIntercepter != null) {
119 Set<String> filesIncludingInterceptableFiles =
120 sIntercepter.getInterceptableResourceList();
121 if (filesIncludingInterceptableFiles != null &&
122 !filesIncludingInterceptableFiles.isEmpty()) {
123 for (String file : files) {
124 filesIncludingInterceptableFiles.add(file);
126 files = new String[filesIncludingInterceptableFiles.size()];
127 filesIncludingInterceptableFiles.toArray(files);
130 for (String file : files) {
131 if (!paksToInstall.matcher(file).matches()) {
134 boolean isICUData = file.equals(ICU_DATA_FILENAME);
135 File output = new File(isICUData ? getAppDataDir() : outputDir, file);
136 if (output.exists()) {
140 InputStream is = null;
141 OutputStream os = null;
143 if (sIntercepter != null) {
144 is = sIntercepter.interceptLoadingForResource(file);
146 if (is == null) is = manager.open(file);
147 os = new FileOutputStream(output);
148 Log.i(LOGTAG, "Extracting resource " + file);
149 if (buffer == null) {
150 buffer = new byte[BUFFER_SIZE];
154 while ((count = is.read(buffer, 0, BUFFER_SIZE)) != -1) {
155 os.write(buffer, 0, count);
159 // Ensure something reasonable was written.
160 if (output.length() == 0) {
161 throw new IOException(file + " extracted with 0 length!");
167 // icudata needs to be accessed by a renderer process.
168 output.setReadable(true, false);
182 } catch (IOException e) {
183 // TODO(benm): See crbug/152413.
184 // Try to recover here, can we try again after deleting files instead of
185 // returning null? It might be useful to gather UMA here too to track if
186 // this happens with regularity.
187 Log.w(LOGTAG, "Exception unpacking required pak resources: " + e.getMessage());
192 // Finished, write out a timestamp file if we need to.
194 if (timestampFile != null) {
196 new File(outputDir, timestampFile).createNewFile();
197 } catch (IOException e) {
198 // Worst case we don't write a timestamp, so we'll re-extract the resource
199 // paks next start up.
200 Log.w(LOGTAG, "Failed to write resource pak timestamp!");
203 // TODO(yusufo): Figure out why remove is required here.
204 prefs.edit().remove(PAK_FILENAMES).apply();
205 prefs.edit().putStringSet(PAK_FILENAMES, filenames).apply();
209 // Looks for a timestamp file on disk that indicates the version of the APK that
210 // the resource paks were extracted from. Returns null if a timestamp was found
211 // and it indicates that the resources match the current APK. Otherwise returns
212 // a String that represents the filename of a timestamp to create.
213 // Note that we do this to avoid adding a BroadcastReceiver on
214 // android.content.Intent#ACTION_PACKAGE_CHANGED as that causes process churn
215 // on (re)installation of *all* APK files.
216 private String checkPakTimestamp(File outputDir) {
217 final String timestampPrefix = "pak_timestamp-";
218 PackageManager pm = mContext.getPackageManager();
219 PackageInfo pi = null;
222 pi = pm.getPackageInfo(mContext.getPackageName(), 0);
223 } catch (PackageManager.NameNotFoundException e) {
224 return timestampPrefix;
228 return timestampPrefix;
231 String expectedTimestamp = timestampPrefix + pi.versionCode + "-" + pi.lastUpdateTime;
233 String[] timestamps = outputDir.list(new FilenameFilter() {
235 public boolean accept(File dir, String name) {
236 return name.startsWith(timestampPrefix);
240 if (timestamps.length != 1) {
241 // If there's no timestamp, nuke to be safe as we can't tell the age of the files.
242 // If there's multiple timestamps, something's gone wrong so nuke.
243 return expectedTimestamp;
246 if (!expectedTimestamp.equals(timestamps[0])) {
247 return expectedTimestamp;
250 // timestamp file is already up-to date.
255 private final Context mContext;
256 private ExtractTask mExtractTask;
258 private static ResourceExtractor sInstance;
260 public static ResourceExtractor get(Context context) {
261 if (sInstance == null) {
262 sInstance = new ResourceExtractor(context);
268 * Specifies the .pak files that should be extracted from the APK's asset resources directory
269 * and moved to {@link #getOutputDirFromContext(Context)}.
270 * @param mandatoryPaks The list of pak files to be loaded. If no pak files are
271 * required, pass a single empty string.
273 public static void setMandatoryPaksToExtract(String... mandatoryPaks) {
274 assert (sInstance == null || sInstance.mExtractTask == null)
275 : "Must be called before startExtractingResources is called";
276 sMandatoryPaks = mandatoryPaks;
281 * Allow embedders to intercept the resource loading process. Embedders may
282 * want to load paks from res/raw instead of assets, since assets are not
283 * supported in Android library project.
284 * @param intercepter The instance of intercepter which provides the files list
285 * to intercept and the inputstream for the files it wants to intercept with.
287 public static void setResourceIntercepter(ResourceIntercepter intercepter) {
288 assert (sInstance == null || sInstance.mExtractTask == null)
289 : "Must be called before startExtractingResources is called";
290 sIntercepter = intercepter;
294 * By default the ResourceExtractor will attempt to extract a pak resource for the users
295 * currently specified locale. This behavior can be changed with this function and is
296 * only needed by tests.
297 * @param extract False if we should not attempt to extract a pak file for
298 * the users currently selected locale and try to extract only the
299 * pak files specified in sMandatoryPaks.
302 public static void setExtractImplicitLocaleForTesting(boolean extract) {
303 assert (sInstance == null || sInstance.mExtractTask == null)
304 : "Must be called before startExtractingResources is called";
305 sExtractImplicitLocalePak = extract;
309 * Marks all the 'pak' resources, packaged as assets, for extraction during
313 public void setExtractAllPaksForTesting() {
314 List<String> pakFileAssets = new ArrayList<String>();
315 AssetManager manager = mContext.getResources().getAssets();
317 String[] files = manager.list("");
318 for (String file : files) {
319 if (file.endsWith(".pak")) pakFileAssets.add(file);
321 } catch (IOException e) {
322 Log.w(LOGTAG, "Exception while accessing assets: " + e.getMessage(), e);
324 setMandatoryPaksToExtract(pakFileAssets.toArray(new String[pakFileAssets.size()]));
327 private ResourceExtractor(Context context) {
328 mContext = context.getApplicationContext();
331 public void waitForCompletion() {
332 if (shouldSkipPakExtraction()) {
336 assert mExtractTask != null;
340 // ResourceExtractor is not needed any more.
341 // Release static objects to avoid leak of Context.
344 } catch (CancellationException e) {
345 // Don't leave the files in an inconsistent state.
347 } catch (ExecutionException e2) {
349 } catch (InterruptedException e3) {
355 * This will extract the application pak resources in an
356 * AsyncTask. Call waitForCompletion() at the point resources
357 * are needed to block until the task completes.
359 public void startExtractingResources() {
360 if (mExtractTask != null) {
364 if (shouldSkipPakExtraction()) {
368 mExtractTask = new ExtractTask();
369 mExtractTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
372 private File getAppDataDir() {
373 return new File(PathUtils.getDataDirectory(mContext));
376 private File getOutputDir() {
377 return new File(getAppDataDir(), "paks");
381 * Pak files (UI strings and other resources) should be updated along with
382 * Chrome. A version mismatch can lead to a rather broken user experience.
383 * The ICU data (icudtl.dat) is less version-sensitive, but still can
384 * lead to malfunction/UX misbehavior. So, we regard failing to update them
387 private void deleteFiles() {
388 File icudata = new File(getAppDataDir(), ICU_DATA_FILENAME);
389 if (icudata.exists() && !icudata.delete()) {
390 Log.e(LOGTAG, "Unable to remove the icudata " + icudata.getName());
392 File dir = getOutputDir();
394 File[] files = dir.listFiles();
395 for (File file : files) {
396 if (!file.delete()) {
397 Log.e(LOGTAG, "Unable to remove existing resource " + file.getName());
404 * Pak extraction not necessarily required by the embedder; we allow them to skip
405 * this process if they call setMandatoryPaksToExtract with a single empty String.
407 private static boolean shouldSkipPakExtraction() {
408 // Must call setMandatoryPaksToExtract before beginning resource extraction.
409 assert sMandatoryPaks != null;
410 return sMandatoryPaks.length == 1 && "".equals(sMandatoryPaks[0]);