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