MultiDexExtractor.java revision 02290864de23cf5d941ba3b18ee5029150322107
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
17package android.support.multidex;
18
19import android.content.Context;
20import android.content.SharedPreferences;
21import android.content.pm.ApplicationInfo;
22import android.os.Build;
23import android.util.Log;
24
25import java.io.BufferedOutputStream;
26import java.io.Closeable;
27import java.io.File;
28import java.io.FileFilter;
29import java.io.FileNotFoundException;
30import java.io.FileOutputStream;
31import java.io.IOException;
32import java.io.InputStream;
33import java.lang.reflect.InvocationTargetException;
34import java.lang.reflect.Method;
35import java.util.ArrayList;
36import java.util.List;
37import java.util.zip.ZipEntry;
38import java.util.zip.ZipException;
39import java.util.zip.ZipFile;
40import java.util.zip.ZipOutputStream;
41
42/**
43 * Exposes application secondary dex files as files in the application data
44 * directory.
45 */
46final 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     */
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
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
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
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
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
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
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
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     */
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
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
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     */
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     */
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
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