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