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.content.browser;
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;
16 import org.chromium.base.PathUtils;
17 import org.chromium.ui.base.LocalizationUtils;
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;
27 import java.util.concurrent.CancellationException;
28 import java.util.concurrent.ExecutionException;
29 import java.util.regex.Pattern;
32 * Handles extracting the necessary resources bundled in an APK and moving them to a location on
33 * the file system accessible from the native code.
35 public class ResourceExtractor {
37 private static final String LOGTAG = "ResourceExtractor";
38 private static final String LAST_LANGUAGE = "Last language";
39 private static final String PAK_FILENAMES = "Pak filenames";
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 if (!mOutputDir.exists() && !mOutputDir.mkdirs()) {
63 Log.e(LOGTAG, "Unable to create pak resources directory!");
67 String timestampFile = checkPakTimestamp();
68 if (timestampFile != null) {
69 deleteFiles(mContext);
72 SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
73 mContext.getApplicationContext());
74 HashSet<String> filenames = (HashSet<String>) prefs.getStringSet(
75 PAK_FILENAMES, new HashSet<String>());
76 String currentLocale = LocalizationUtils.getDefaultLocale();
77 String currentLanguage = currentLocale.split("-", 2)[0];
79 if (prefs.getString(LAST_LANGUAGE, "").equals(currentLanguage)
80 && filenames.size() >= sMandatoryPaks.length) {
81 boolean filesPresent = true;
82 for (String file : filenames) {
83 if (!new File(mOutputDir, file).exists()) {
88 if (filesPresent) return null;
90 prefs.edit().putString(LAST_LANGUAGE, currentLanguage).apply();
93 StringBuilder p = new StringBuilder();
94 for (String mandatoryPak : sMandatoryPaks) {
95 if (p.length() > 0) p.append('|');
96 p.append("\\Q" + mandatoryPak + "\\E");
99 if (sExtractImplicitLocalePak) {
100 if (p.length() > 0) p.append('|');
101 // As well as the minimum required set of .paks above, we'll also add all .paks that
102 // we have for the user's currently selected language.
104 p.append(currentLanguage);
105 p.append("(-\\w+)?\\.pak");
107 Pattern paksToInstall = Pattern.compile(p.toString());
109 AssetManager manager = mContext.getResources().getAssets();
111 // Loop through every asset file that we have in the APK, and look for the
112 // ones that we need to extract by trying to match the Patterns that we
114 byte[] buffer = null;
115 String[] files = manager.list("");
116 if (sIntercepter != null) {
117 Set<String> filesIncludingInterceptableFiles =
118 sIntercepter.getInterceptableResourceList();
119 if (filesIncludingInterceptableFiles != null &&
120 !filesIncludingInterceptableFiles.isEmpty()) {
121 for (String file : files) {
122 filesIncludingInterceptableFiles.add(file);
124 files = new String[filesIncludingInterceptableFiles.size()];
125 filesIncludingInterceptableFiles.toArray(files);
128 for (String file : files) {
129 if (!paksToInstall.matcher(file).matches()) {
132 File output = new File(mOutputDir, file);
133 if (output.exists()) {
137 InputStream is = null;
138 OutputStream os = null;
140 if (sIntercepter != null) {
141 is = sIntercepter.interceptLoadingForResource(file);
143 if (is == null) is = manager.open(file);
144 os = new FileOutputStream(output);
145 Log.i(LOGTAG, "Extracting resource " + file);
146 if (buffer == null) {
147 buffer = new byte[BUFFER_SIZE];
151 while ((count = is.read(buffer, 0, BUFFER_SIZE)) != -1) {
152 os.write(buffer, 0, count);
156 // Ensure something reasonable was written.
157 if (output.length() == 0) {
158 throw new IOException(file + " extracted with 0 length!");
174 } catch (IOException e) {
175 // TODO(benm): See crbug/152413.
176 // Try to recover here, can we try again after deleting files instead of
177 // returning null? It might be useful to gather UMA here too to track if
178 // this happens with regularity.
179 Log.w(LOGTAG, "Exception unpacking required pak resources: " + e.getMessage());
180 deleteFiles(mContext);
184 // Finished, write out a timestamp file if we need to.
186 if (timestampFile != null) {
188 new File(mOutputDir, timestampFile).createNewFile();
189 } catch (IOException e) {
190 // Worst case we don't write a timestamp, so we'll re-extract the resource
191 // paks next start up.
192 Log.w(LOGTAG, "Failed to write resource pak timestamp!");
195 // TODO(yusufo): Figure out why remove is required here.
196 prefs.edit().remove(PAK_FILENAMES).apply();
197 prefs.edit().putStringSet(PAK_FILENAMES, filenames).apply();
201 // Looks for a timestamp file on disk that indicates the version of the APK that
202 // the resource paks were extracted from. Returns null if a timestamp was found
203 // and it indicates that the resources match the current APK. Otherwise returns
204 // a String that represents the filename of a timestamp to create.
205 // Note that we do this to avoid adding a BroadcastReceiver on
206 // android.content.Intent#ACTION_PACKAGE_CHANGED as that causes process churn
207 // on (re)installation of *all* APK files.
208 private String checkPakTimestamp() {
209 final String TIMESTAMP_PREFIX = "pak_timestamp-";
210 PackageManager pm = mContext.getPackageManager();
211 PackageInfo pi = null;
214 pi = pm.getPackageInfo(mContext.getPackageName(), 0);
215 } catch (PackageManager.NameNotFoundException e) {
216 return TIMESTAMP_PREFIX;
220 return TIMESTAMP_PREFIX;
223 String expectedTimestamp = TIMESTAMP_PREFIX + pi.versionCode + "-" + pi.lastUpdateTime;
225 String[] timestamps = mOutputDir.list(new FilenameFilter() {
227 public boolean accept(File dir, String name) {
228 return name.startsWith(TIMESTAMP_PREFIX);
232 if (timestamps.length != 1) {
233 // If there's no timestamp, nuke to be safe as we can't tell the age of the files.
234 // If there's multiple timestamps, something's gone wrong so nuke.
235 return expectedTimestamp;
238 if (!expectedTimestamp.equals(timestamps[0])) {
239 return expectedTimestamp;
242 // timestamp file is already up-to date.
247 private final Context mContext;
248 private ExtractTask mExtractTask;
249 private final File mOutputDir;
251 private static ResourceExtractor sInstance;
253 public static ResourceExtractor get(Context context) {
254 if (sInstance == null) {
255 sInstance = new ResourceExtractor(context);
261 * Specifies the .pak files that should be extracted from the APK's asset resources directory
262 * and moved to {@link #getOutputDirFromContext(Context)}.
263 * @param mandatoryPaks The list of pak files to be loaded. If no pak files are
264 * required, pass a single empty string.
266 public static void setMandatoryPaksToExtract(String... mandatoryPaks) {
267 assert (sInstance == null || sInstance.mExtractTask == null)
268 : "Must be called before startExtractingResources is called";
269 sMandatoryPaks = mandatoryPaks;
274 * Allow embedders to intercept the resource loading process. Embedders may
275 * want to load paks from res/raw instead of assets, since assets are not
276 * supported in Android library project.
277 * @param intercepter The instance of intercepter which provides the files list
278 * to intercept and the inputstream for the files it wants to intercept with.
280 public static void setResourceIntercepter(ResourceIntercepter intercepter) {
281 assert (sInstance == null || sInstance.mExtractTask == null)
282 : "Must be called before startExtractingResources is called";
283 sIntercepter = intercepter;
287 * By default the ResourceExtractor will attempt to extract a pak resource for the users
288 * currently specified locale. This behavior can be changed with this function and is
289 * only needed by tests.
290 * @param extract False if we should not attempt to extract a pak file for
291 * the users currently selected locale and try to extract only the
292 * pak files specified in sMandatoryPaks.
294 public static void setExtractImplicitLocaleForTesting(boolean extract) {
295 assert (sInstance == null || sInstance.mExtractTask == null)
296 : "Must be called before startExtractingResources is called";
297 sExtractImplicitLocalePak = extract;
300 private ResourceExtractor(Context context) {
302 mOutputDir = getOutputDirFromContext(mContext);
305 public void waitForCompletion() {
306 if (shouldSkipPakExtraction()) {
310 assert mExtractTask != null;
314 } catch (CancellationException e) {
315 // Don't leave the files in an inconsistent state.
316 deleteFiles(mContext);
317 } catch (ExecutionException e2) {
318 deleteFiles(mContext);
319 } catch (InterruptedException e3) {
320 deleteFiles(mContext);
325 * This will extract the application pak resources in an
326 * AsyncTask. Call waitForCompletion() at the point resources
327 * are needed to block until the task completes.
329 public void startExtractingResources() {
330 if (mExtractTask != null) {
334 if (shouldSkipPakExtraction()) {
338 mExtractTask = new ExtractTask();
339 mExtractTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
342 public static File getOutputDirFromContext(Context context) {
343 return new File(PathUtils.getDataDirectory(context.getApplicationContext()), "paks");
346 public static void deleteFiles(Context context) {
347 File dir = getOutputDirFromContext(context);
349 File[] files = dir.listFiles();
350 for (File file : files) {
351 if (!file.delete()) {
352 Log.w(LOGTAG, "Unable to remove existing resource " + file.getName());
359 * Pak extraction not necessarily required by the embedder; we allow them to skip
360 * this process if they call setMandatoryPaksToExtract with a single empty String.
362 private static boolean shouldSkipPakExtraction() {
363 // Must call setMandatoryPaksToExtract before beginning resource extraction.
364 assert sMandatoryPaks != null;
365 return sMandatoryPaks.length == 1 && "".equals(sMandatoryPaks[0]);