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.annotation.TargetApi; 8 import android.content.Context; 9 import android.content.SharedPreferences; 10 import android.content.pm.PackageInfo; 11 import android.content.pm.PackageManager; 12 import android.os.AsyncTask; 13 import android.os.Build; 14 import android.os.Handler; 15 import android.os.Looper; 16 import android.os.Trace; 17 18 import org.chromium.base.annotations.SuppressFBWarnings; 19 20 import java.io.File; 21 import java.io.FileOutputStream; 22 import java.io.IOException; 23 import java.io.InputStream; 24 import java.io.OutputStream; 25 import java.util.ArrayList; 26 import java.util.List; 27 import java.util.concurrent.CancellationException; 28 import java.util.concurrent.ExecutionException; 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 TAG = "cr.base"; 37 private static final String ICU_DATA_FILENAME = "icudtl.dat"; 38 private static final String V8_NATIVES_DATA_FILENAME = "natives_blob.bin"; 39 private static final String V8_SNAPSHOT_DATA_FILENAME = "snapshot_blob.bin"; 40 private static final String APP_VERSION_PREF = "org.chromium.base.ResourceExtractor.Version"; 41 42 private static ResourceEntry[] sResourcesToExtract = new ResourceEntry[0]; 43 44 /** 45 * Holds information about a res/raw file (e.g. locale .pak files). 46 */ 47 public static final class ResourceEntry { 48 public final int resourceId; 49 public final String pathWithinApk; 50 public final String extractedFileName; 51 ResourceEntry(int resourceId, String pathWithinApk, String extractedFileName)52 public ResourceEntry(int resourceId, String pathWithinApk, String extractedFileName) { 53 this.resourceId = resourceId; 54 this.pathWithinApk = pathWithinApk; 55 this.extractedFileName = extractedFileName; 56 } 57 } 58 59 private class ExtractTask extends AsyncTask<Void, Void, Void> { 60 private static final int BUFFER_SIZE = 16 * 1024; 61 62 private final List<Runnable> mCompletionCallbacks = new ArrayList<Runnable>(); 63 extractResourceHelper(InputStream is, File outFile, byte[] buffer)64 private void extractResourceHelper(InputStream is, File outFile, byte[] buffer) 65 throws IOException { 66 OutputStream os = null; 67 try { 68 os = new FileOutputStream(outFile); 69 Log.i(TAG, "Extracting resource %s", outFile); 70 71 int count = 0; 72 while ((count = is.read(buffer, 0, BUFFER_SIZE)) != -1) { 73 os.write(buffer, 0, count); 74 } 75 } finally { 76 try { 77 if (os != null) { 78 os.close(); 79 } 80 } finally { 81 if (is != null) { 82 is.close(); 83 } 84 } 85 } 86 } 87 doInBackgroundImpl()88 private void doInBackgroundImpl() { 89 final File outputDir = getOutputDir(); 90 if (!outputDir.exists() && !outputDir.mkdirs()) { 91 Log.e(TAG, "Unable to create pak resources directory!"); 92 return; 93 } 94 95 beginTraceSection("checkPakTimeStamp"); 96 long curAppVersion = getApkVersion(); 97 SharedPreferences sharedPrefs = ContextUtils.getAppSharedPreferences(); 98 long prevAppVersion = sharedPrefs.getLong(APP_VERSION_PREF, 0); 99 boolean versionChanged = curAppVersion != prevAppVersion; 100 endTraceSection(); 101 102 if (versionChanged) { 103 deleteFiles(); 104 // Use the version only to see if files should be deleted, not to skip extraction. 105 // We've seen files be corrupted, so always attempt extraction. 106 // http://crbug.com/606413 107 sharedPrefs.edit().putLong(APP_VERSION_PREF, curAppVersion).apply(); 108 } 109 110 beginTraceSection("WalkAssets"); 111 byte[] buffer = new byte[BUFFER_SIZE]; 112 try { 113 for (ResourceEntry entry : sResourcesToExtract) { 114 File output = new File(outputDir, entry.extractedFileName); 115 // TODO(agrieve): It would be better to check that .length == expectedLength. 116 // http://crbug.com/606413 117 if (output.length() != 0) { 118 continue; 119 } 120 beginTraceSection("ExtractResource"); 121 InputStream inputStream = mContext.getResources().openRawResource( 122 entry.resourceId); 123 try { 124 extractResourceHelper(inputStream, output, buffer); 125 } finally { 126 endTraceSection(); // ExtractResource 127 } 128 } 129 } catch (IOException e) { 130 // TODO(benm): See crbug/152413. 131 // Try to recover here, can we try again after deleting files instead of 132 // returning null? It might be useful to gather UMA here too to track if 133 // this happens with regularity. 134 Log.w(TAG, "Exception unpacking required pak resources: %s", e.getMessage()); 135 deleteFiles(); 136 return; 137 } finally { 138 endTraceSection(); // WalkAssets 139 } 140 } 141 142 @Override doInBackground(Void... unused)143 protected Void doInBackground(Void... unused) { 144 // TODO(lizeb): Use chrome tracing here (and above in 145 // doInBackgroundImpl) when it will be possible. This is currently 146 // not doable since the native library is not loaded yet, and the 147 // TraceEvent calls are dropped before this point. 148 beginTraceSection("ResourceExtractor.ExtractTask.doInBackground"); 149 try { 150 doInBackgroundImpl(); 151 } finally { 152 endTraceSection(); 153 } 154 return null; 155 } 156 onPostExecuteImpl()157 private void onPostExecuteImpl() { 158 for (int i = 0; i < mCompletionCallbacks.size(); i++) { 159 mCompletionCallbacks.get(i).run(); 160 } 161 mCompletionCallbacks.clear(); 162 } 163 164 @Override onPostExecute(Void result)165 protected void onPostExecute(Void result) { 166 beginTraceSection("ResourceExtractor.ExtractTask.onPostExecute"); 167 try { 168 onPostExecuteImpl(); 169 } finally { 170 endTraceSection(); 171 } 172 } 173 174 /** Returns a number that is different each time the apk changes. */ getApkVersion()175 private long getApkVersion() { 176 PackageManager pm = mContext.getPackageManager(); 177 try { 178 // More appropriate would be versionCode, but it doesn't change while developing. 179 PackageInfo pi = pm.getPackageInfo(mContext.getPackageName(), 0); 180 return pi.lastUpdateTime; 181 } catch (PackageManager.NameNotFoundException e) { 182 throw new RuntimeException(e); 183 } 184 } 185 186 @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) beginTraceSection(String section)187 private void beginTraceSection(String section) { 188 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return; 189 Trace.beginSection(section); 190 } 191 192 @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2) endTraceSection()193 private void endTraceSection() { 194 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR2) return; 195 Trace.endSection(); 196 } 197 } 198 199 private final Context mContext; 200 private ExtractTask mExtractTask; 201 202 private static ResourceExtractor sInstance; 203 get(Context context)204 public static ResourceExtractor get(Context context) { 205 if (sInstance == null) { 206 sInstance = new ResourceExtractor(context); 207 } 208 return sInstance; 209 } 210 211 /** 212 * Specifies the files that should be extracted from the APK. 213 * and moved to {@link #getOutputDir()}. 214 */ 215 @SuppressFBWarnings("EI_EXPOSE_STATIC_REP2") setResourcesToExtract(ResourceEntry[] entries)216 public static void setResourcesToExtract(ResourceEntry[] entries) { 217 assert (sInstance == null || sInstance.mExtractTask == null) 218 : "Must be called before startExtractingResources is called"; 219 sResourcesToExtract = entries; 220 } 221 ResourceExtractor(Context context)222 private ResourceExtractor(Context context) { 223 mContext = context.getApplicationContext(); 224 } 225 226 /** 227 * Synchronously wait for the resource extraction to be completed. 228 * <p> 229 * This method is bad and you should feel bad for using it. 230 * 231 * @see #addCompletionCallback(Runnable) 232 */ waitForCompletion()233 public void waitForCompletion() { 234 if (shouldSkipPakExtraction()) { 235 return; 236 } 237 238 assert mExtractTask != null; 239 240 try { 241 mExtractTask.get(); 242 } catch (CancellationException e) { 243 // Don't leave the files in an inconsistent state. 244 deleteFiles(); 245 } catch (ExecutionException e2) { 246 deleteFiles(); 247 } catch (InterruptedException e3) { 248 deleteFiles(); 249 } 250 } 251 252 /** 253 * Adds a callback to be notified upon the completion of resource extraction. 254 * <p> 255 * If the resource task has already completed, the callback will be posted to the UI message 256 * queue. Otherwise, it will be executed after all the resources have been extracted. 257 * <p> 258 * This must be called on the UI thread. The callback will also always be executed on 259 * the UI thread. 260 * 261 * @param callback The callback to be enqueued. 262 */ addCompletionCallback(Runnable callback)263 public void addCompletionCallback(Runnable callback) { 264 ThreadUtils.assertOnUiThread(); 265 266 Handler handler = new Handler(Looper.getMainLooper()); 267 if (shouldSkipPakExtraction()) { 268 handler.post(callback); 269 return; 270 } 271 272 assert mExtractTask != null; 273 assert !mExtractTask.isCancelled(); 274 if (mExtractTask.getStatus() == AsyncTask.Status.FINISHED) { 275 handler.post(callback); 276 } else { 277 mExtractTask.mCompletionCallbacks.add(callback); 278 } 279 } 280 281 /** 282 * This will extract the application pak resources in an 283 * AsyncTask. Call waitForCompletion() at the point resources 284 * are needed to block until the task completes. 285 */ startExtractingResources()286 public void startExtractingResources() { 287 if (mExtractTask != null) { 288 return; 289 } 290 291 // If a previous release extracted resources, and the current release does not, 292 // deleteFiles() will not run and some files will be left. This currently 293 // can happen for ContentShell, but not for Chrome proper, since we always extract 294 // locale pak files. 295 if (shouldSkipPakExtraction()) { 296 return; 297 } 298 299 mExtractTask = new ExtractTask(); 300 mExtractTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); 301 } 302 getAppDataDir()303 private File getAppDataDir() { 304 return new File(PathUtils.getDataDirectory(mContext)); 305 } 306 getOutputDir()307 private File getOutputDir() { 308 return new File(getAppDataDir(), "paks"); 309 } 310 311 /** 312 * Pak files (UI strings and other resources) should be updated along with 313 * Chrome. A version mismatch can lead to a rather broken user experience. 314 * Failing to update the V8 snapshot files will lead to a version mismatch 315 * between V8 and the loaded snapshot which will cause V8 to crash, so this 316 * is treated as an error. The ICU data (icudtl.dat) is less 317 * version-sensitive, but still can lead to malfunction/UX misbehavior. So, 318 * we regard failing to update them as an error. 319 */ deleteFiles()320 private void deleteFiles() { 321 File icudata = new File(getAppDataDir(), ICU_DATA_FILENAME); 322 if (icudata.exists() && !icudata.delete()) { 323 Log.e(TAG, "Unable to remove the icudata %s", icudata.getName()); 324 } 325 File v8_natives = new File(getAppDataDir(), V8_NATIVES_DATA_FILENAME); 326 if (v8_natives.exists() && !v8_natives.delete()) { 327 Log.e(TAG, "Unable to remove the v8 data %s", v8_natives.getName()); 328 } 329 File v8_snapshot = new File(getAppDataDir(), V8_SNAPSHOT_DATA_FILENAME); 330 if (v8_snapshot.exists() && !v8_snapshot.delete()) { 331 Log.e(TAG, "Unable to remove the v8 data %s", v8_snapshot.getName()); 332 } 333 File dir = getOutputDir(); 334 if (dir.exists()) { 335 File[] files = dir.listFiles(); 336 337 if (files != null) { 338 for (File file : files) { 339 if (!file.delete()) { 340 Log.e(TAG, "Unable to remove existing resource %s", file.getName()); 341 } 342 } 343 } 344 } 345 } 346 347 /** 348 * Pak extraction not necessarily required by the embedder. 349 */ shouldSkipPakExtraction()350 private static boolean shouldSkipPakExtraction() { 351 return sResourcesToExtract.length == 0; 352 } 353 } 354