1 /*
2  * Copyright (C) 2013 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.support.multidex;
18 
19 import android.content.Context;
20 import android.content.SharedPreferences;
21 import android.content.pm.ApplicationInfo;
22 import android.os.Build;
23 import android.util.Log;
24 
25 import java.io.BufferedOutputStream;
26 import java.io.Closeable;
27 import java.io.File;
28 import java.io.FileFilter;
29 import java.io.FileNotFoundException;
30 import java.io.FileOutputStream;
31 import java.io.IOException;
32 import java.io.InputStream;
33 import java.lang.reflect.InvocationTargetException;
34 import java.lang.reflect.Method;
35 import java.util.ArrayList;
36 import java.util.List;
37 import java.util.zip.ZipEntry;
38 import java.util.zip.ZipException;
39 import java.util.zip.ZipFile;
40 import java.util.zip.ZipOutputStream;
41 
42 /**
43  * Exposes application secondary dex files as files in the application data
44  * directory.
45  */
46 final class MultiDexExtractor {
47 
48     private static final String TAG = MultiDex.TAG;
49 
50     /**
51      * We look for additional dex files named {@code classes2.dex},
52      * {@code classes3.dex}, etc.
53      */
54     private static final String DEX_PREFIX = "classes";
55     private static final String DEX_SUFFIX = ".dex";
56 
57     private static final String EXTRACTED_NAME_EXT = ".classes";
58     private static final String EXTRACTED_SUFFIX = ".zip";
59     private static final int MAX_EXTRACT_ATTEMPTS = 3;
60 
61     private static final String PREFS_FILE = "multidex.version";
62     private static final String KEY_TIME_STAMP = "timestamp";
63     private static final String KEY_CRC = "crc";
64     private static final String KEY_DEX_NUMBER = "dex.number";
65 
66     /**
67      * Size of reading buffers.
68      */
69     private static final int BUFFER_SIZE = 0x4000;
70     /* Keep value away from 0 because it is a too probable time stamp value */
71     private static final long NO_VALUE = -1L;
72 
73     /**
74      * Extracts application secondary dexes into files in the application data
75      * directory.
76      *
77      * @return a list of files that were created. The list may be empty if there
78      *         are no secondary dex files.
79      * @throws IOException if encounters a problem while reading or writing
80      *         secondary dex files
81      */
load(Context context, ApplicationInfo applicationInfo, File dexDir, boolean forceReload)82     static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir,
83             boolean forceReload) throws IOException {
84         Log.i(TAG, "MultiDexExtractor.load(" + applicationInfo.sourceDir + ", " + forceReload + ")");
85         final File sourceApk = new File(applicationInfo.sourceDir);
86 
87         long currentCrc = getZipCrc(sourceApk);
88 
89         List<File> files;
90         if (!forceReload && !isModified(context, sourceApk, currentCrc)) {
91             try {
92                 files = loadExistingExtractions(context, sourceApk, dexDir);
93             } catch (IOException ioe) {
94                 Log.w(TAG, "Failed to reload existing extracted secondary dex files,"
95                         + " falling back to fresh extraction", ioe);
96                 files = performExtractions(sourceApk, dexDir);
97                 putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
98 
99             }
100         } else {
101             Log.i(TAG, "Detected that extraction must be performed.");
102             files = performExtractions(sourceApk, dexDir);
103             putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
104         }
105 
106         Log.i(TAG, "load found " + files.size() + " secondary dex files");
107         return files;
108     }
109 
loadExistingExtractions(Context context, File sourceApk, File dexDir)110     private static List<File> loadExistingExtractions(Context context, File sourceApk, File dexDir)
111             throws IOException {
112         Log.i(TAG, "loading existing secondary dex files");
113 
114         final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
115         int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);
116         final List<File> files = new ArrayList<File>(totalDexNumber);
117 
118         for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
119             String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
120             File extractedFile = new File(dexDir, fileName);
121             if (extractedFile.isFile()) {
122                 files.add(extractedFile);
123                 if (!verifyZipFile(extractedFile)) {
124                     Log.i(TAG, "Invalid zip file: " + extractedFile);
125                     throw new IOException("Invalid ZIP file.");
126                 }
127             } else {
128                 throw new IOException("Missing extracted secondary dex file '" +
129                         extractedFile.getPath() + "'");
130             }
131         }
132 
133         return files;
134     }
135 
isModified(Context context, File archive, long currentCrc)136     private static boolean isModified(Context context, File archive, long currentCrc) {
137         SharedPreferences prefs = getMultiDexPreferences(context);
138         return (prefs.getLong(KEY_TIME_STAMP, NO_VALUE) != getTimeStamp(archive))
139                 || (prefs.getLong(KEY_CRC, NO_VALUE) != currentCrc);
140     }
141 
getTimeStamp(File archive)142     private static long getTimeStamp(File archive) {
143         long timeStamp = archive.lastModified();
144         if (timeStamp == NO_VALUE) {
145             // never return NO_VALUE
146             timeStamp--;
147         }
148         return timeStamp;
149     }
150 
151 
getZipCrc(File archive)152     private static long getZipCrc(File archive) throws IOException {
153         long computedValue = ZipUtil.getZipCrc(archive);
154         if (computedValue == NO_VALUE) {
155             // never return NO_VALUE
156             computedValue--;
157         }
158         return computedValue;
159     }
160 
performExtractions(File sourceApk, File dexDir)161     private static List<File> performExtractions(File sourceApk, File dexDir)
162             throws IOException {
163 
164         final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
165 
166         // Ensure that whatever deletions happen in prepareDexDir only happen if the zip that
167         // contains a secondary dex file in there is not consistent with the latest apk.  Otherwise,
168         // multi-process race conditions can cause a crash loop where one process deletes the zip
169         // while another had created it.
170         prepareDexDir(dexDir, extractedFilePrefix);
171 
172         List<File> files = new ArrayList<File>();
173 
174         final ZipFile apk = new ZipFile(sourceApk);
175         try {
176 
177             int secondaryNumber = 2;
178 
179             ZipEntry dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
180             while (dexFile != null) {
181                 String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
182                 File extractedFile = new File(dexDir, fileName);
183                 files.add(extractedFile);
184 
185                 Log.i(TAG, "Extraction is needed for file " + extractedFile);
186                 int numAttempts = 0;
187                 boolean isExtractionSuccessful = false;
188                 while (numAttempts < MAX_EXTRACT_ATTEMPTS && !isExtractionSuccessful) {
189                     numAttempts++;
190 
191                     // Create a zip file (extractedFile) containing only the secondary dex file
192                     // (dexFile) from the apk.
193                     extract(apk, dexFile, extractedFile, extractedFilePrefix);
194 
195                     // Verify that the extracted file is indeed a zip file.
196                     isExtractionSuccessful = verifyZipFile(extractedFile);
197 
198                     // Log the sha1 of the extracted zip file
199                     Log.i(TAG, "Extraction " + (isExtractionSuccessful ? "success" : "failed") +
200                             " - length " + extractedFile.getAbsolutePath() + ": " +
201                             extractedFile.length());
202                     if (!isExtractionSuccessful) {
203                         // Delete the extracted file
204                         extractedFile.delete();
205                         if (extractedFile.exists()) {
206                             Log.w(TAG, "Failed to delete corrupted secondary dex '" +
207                                     extractedFile.getPath() + "'");
208                         }
209                     }
210                 }
211                 if (!isExtractionSuccessful) {
212                     throw new IOException("Could not create zip file " +
213                             extractedFile.getAbsolutePath() + " for secondary dex (" +
214                             secondaryNumber + ")");
215                 }
216                 secondaryNumber++;
217                 dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
218             }
219         } finally {
220             try {
221                 apk.close();
222             } catch (IOException e) {
223                 Log.w(TAG, "Failed to close resource", e);
224             }
225         }
226 
227         return files;
228     }
229 
putStoredApkInfo(Context context, long timeStamp, long crc, int totalDexNumber)230     private static void putStoredApkInfo(Context context, long timeStamp, long crc,
231             int totalDexNumber) {
232         SharedPreferences prefs = getMultiDexPreferences(context);
233         SharedPreferences.Editor edit = prefs.edit();
234         edit.putLong(KEY_TIME_STAMP, timeStamp);
235         edit.putLong(KEY_CRC, crc);
236         /* SharedPreferences.Editor doc says that apply() and commit() "atomically performs the
237          * requested modifications" it should be OK to rely on saving the dex files number (getting
238          * old number value would go along with old crc and time stamp).
239          */
240         edit.putInt(KEY_DEX_NUMBER, totalDexNumber);
241         apply(edit);
242     }
243 
getMultiDexPreferences(Context context)244     private static SharedPreferences getMultiDexPreferences(Context context) {
245         return context.getSharedPreferences(PREFS_FILE,
246                 Build.VERSION.SDK_INT < 11 /* Build.VERSION_CODES.HONEYCOMB */
247                         ? Context.MODE_PRIVATE
248                         : Context.MODE_PRIVATE | 0x0004 /* Context.MODE_MULTI_PROCESS */);
249     }
250 
251     /**
252      * This removes any files that do not have the correct prefix.
253      */
prepareDexDir(File dexDir, final String extractedFilePrefix)254     private static void prepareDexDir(File dexDir, final String extractedFilePrefix)
255             throws IOException {
256         /* mkdirs() has some bugs, especially before jb-mr1 and we have only a maximum of one parent
257          * to create, lets stick to mkdir().
258          */
259         File cache = dexDir.getParentFile();
260         mkdirChecked(cache);
261         mkdirChecked(dexDir);
262 
263         // Clean possible old files
264         FileFilter filter = new FileFilter() {
265 
266             @Override
267             public boolean accept(File pathname) {
268                 return !pathname.getName().startsWith(extractedFilePrefix);
269             }
270         };
271         File[] files = dexDir.listFiles(filter);
272         if (files == null) {
273             Log.w(TAG, "Failed to list secondary dex dir content (" + dexDir.getPath() + ").");
274             return;
275         }
276         for (File oldFile : files) {
277             Log.i(TAG, "Trying to delete old file " + oldFile.getPath() + " of size " +
278                     oldFile.length());
279             if (!oldFile.delete()) {
280                 Log.w(TAG, "Failed to delete old file " + oldFile.getPath());
281             } else {
282                 Log.i(TAG, "Deleted old file " + oldFile.getPath());
283             }
284         }
285     }
286 
mkdirChecked(File dir)287     private static void mkdirChecked(File dir) throws IOException {
288         dir.mkdir();
289         if (!dir.isDirectory()) {
290             File parent = dir.getParentFile();
291             if (parent == null) {
292                 Log.e(TAG, "Failed to create dir " + dir.getPath() + ". Parent file is null.");
293             } else {
294                 Log.e(TAG, "Failed to create dir " + dir.getPath() +
295                         ". parent file is a dir " + parent.isDirectory() +
296                         ", a file " + parent.isFile() +
297                         ", exists " + parent.exists() +
298                         ", readable " + parent.canRead() +
299                         ", writable " + parent.canWrite());
300             }
301             throw new IOException("Failed to create cache directory " + dir.getPath());
302         }
303     }
304 
extract(ZipFile apk, ZipEntry dexFile, File extractTo, String extractedFilePrefix)305     private static void extract(ZipFile apk, ZipEntry dexFile, File extractTo,
306             String extractedFilePrefix) throws IOException, FileNotFoundException {
307 
308         InputStream in = apk.getInputStream(dexFile);
309         ZipOutputStream out = null;
310         File tmp = File.createTempFile(extractedFilePrefix, EXTRACTED_SUFFIX,
311                 extractTo.getParentFile());
312         Log.i(TAG, "Extracting " + tmp.getPath());
313         try {
314             out = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(tmp)));
315             try {
316                 ZipEntry classesDex = new ZipEntry("classes.dex");
317                 // keep zip entry time since it is the criteria used by Dalvik
318                 classesDex.setTime(dexFile.getTime());
319                 out.putNextEntry(classesDex);
320 
321                 byte[] buffer = new byte[BUFFER_SIZE];
322                 int length = in.read(buffer);
323                 while (length != -1) {
324                     out.write(buffer, 0, length);
325                     length = in.read(buffer);
326                 }
327                 out.closeEntry();
328             } finally {
329                 out.close();
330             }
331             Log.i(TAG, "Renaming to " + extractTo.getPath());
332             if (!tmp.renameTo(extractTo)) {
333                 throw new IOException("Failed to rename \"" + tmp.getAbsolutePath() +
334                         "\" to \"" + extractTo.getAbsolutePath() + "\"");
335             }
336         } finally {
337             closeQuietly(in);
338             tmp.delete(); // return status ignored
339         }
340     }
341 
342     /**
343      * Returns whether the file is a valid zip file.
344      */
verifyZipFile(File file)345     static boolean verifyZipFile(File file) {
346         try {
347             ZipFile zipFile = new ZipFile(file);
348             try {
349                 zipFile.close();
350                 return true;
351             } catch (IOException e) {
352                 Log.w(TAG, "Failed to close zip file: " + file.getAbsolutePath());
353             }
354         } catch (ZipException ex) {
355             Log.w(TAG, "File " + file.getAbsolutePath() + " is not a valid zip file.", ex);
356         } catch (IOException ex) {
357             Log.w(TAG, "Got an IOException trying to open zip file: " + file.getAbsolutePath(), ex);
358         }
359         return false;
360     }
361 
362     /**
363      * Closes the given {@code Closeable}. Suppresses any IO exceptions.
364      */
closeQuietly(Closeable closeable)365     private static void closeQuietly(Closeable closeable) {
366         try {
367             closeable.close();
368         } catch (IOException e) {
369             Log.w(TAG, "Failed to close resource", e);
370         }
371     }
372 
373     // The following is taken from SharedPreferencesCompat to avoid having a dependency of the
374     // multidex support library on another support library.
375     private static Method sApplyMethod;  // final
376     static {
377         try {
378             Class<?> cls = SharedPreferences.Editor.class;
379             sApplyMethod = cls.getMethod("apply");
380         } catch (NoSuchMethodException unused) {
381             sApplyMethod = null;
382         }
383     }
384 
apply(SharedPreferences.Editor editor)385     private static void apply(SharedPreferences.Editor editor) {
386         if (sApplyMethod != null) {
387             try {
388                 sApplyMethod.invoke(editor);
389                 return;
390             } catch (InvocationTargetException unused) {
391                 // fall through
392             } catch (IllegalAccessException unused) {
393                 // fall through
394             }
395         }
396         editor.commit();
397     }
398 }
399