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.io.RandomAccessFile;
34import java.nio.channels.FileChannel;
35import java.nio.channels.FileLock;
36import java.util.ArrayList;
37import java.util.List;
38import java.util.zip.ZipEntry;
39import java.util.zip.ZipException;
40import java.util.zip.ZipFile;
41import java.util.zip.ZipOutputStream;
42
43/**
44 * Exposes application secondary dex files as files in the application data
45 * directory.
46 */
47final class MultiDexExtractor {
48
49    private static final String TAG = MultiDex.TAG;
50
51    /**
52     * We look for additional dex files named {@code classes2.dex},
53     * {@code classes3.dex}, etc.
54     */
55    private static final String DEX_PREFIX = "classes";
56    private static final String DEX_SUFFIX = ".dex";
57
58    private static final String EXTRACTED_NAME_EXT = ".classes";
59    private static final String EXTRACTED_SUFFIX = ".zip";
60    private static final int MAX_EXTRACT_ATTEMPTS = 3;
61
62    private static final String PREFS_FILE = "multidex.version";
63    private static final String KEY_TIME_STAMP = "timestamp";
64    private static final String KEY_CRC = "crc";
65    private static final String KEY_DEX_NUMBER = "dex.number";
66
67    /**
68     * Size of reading buffers.
69     */
70    private static final int BUFFER_SIZE = 0x4000;
71    /* Keep value away from 0 because it is a too probable time stamp value */
72    private static final long NO_VALUE = -1L;
73
74    private static final String LOCK_FILENAME = "MultiDex.lock";
75
76    /**
77     * Extracts application secondary dexes into files in the application data
78     * directory.
79     *
80     * @return a list of files that were created. The list may be empty if there
81     *         are no secondary dex files. Never return null.
82     * @throws IOException if encounters a problem while reading or writing
83     *         secondary dex files
84     */
85    static List<File> load(Context context, ApplicationInfo applicationInfo, File dexDir,
86            boolean forceReload) throws IOException {
87        Log.i(TAG, "MultiDexExtractor.load(" + applicationInfo.sourceDir + ", " + forceReload + ")");
88        final File sourceApk = new File(applicationInfo.sourceDir);
89
90        long currentCrc = getZipCrc(sourceApk);
91
92        // Validity check and extraction must be done only while the lock file has been taken.
93        File lockFile = new File(dexDir, LOCK_FILENAME);
94        RandomAccessFile lockRaf = new RandomAccessFile(lockFile, "rw");
95        FileChannel lockChannel = null;
96        FileLock cacheLock = null;
97        List<File> files;
98        IOException releaseLockException = null;
99        try {
100            lockChannel = lockRaf.getChannel();
101            Log.i(TAG, "Blocking on lock " + lockFile.getPath());
102            cacheLock = lockChannel.lock();
103            Log.i(TAG, lockFile.getPath() + " locked");
104
105            if (!forceReload && !isModified(context, sourceApk, currentCrc)) {
106                try {
107                    files = loadExistingExtractions(context, sourceApk, dexDir);
108                } catch (IOException ioe) {
109                    Log.w(TAG, "Failed to reload existing extracted secondary dex files,"
110                            + " falling back to fresh extraction", ioe);
111                    files = performExtractions(sourceApk, dexDir);
112                    putStoredApkInfo(context,
113                            getTimeStamp(sourceApk), currentCrc, files.size() + 1);
114
115                }
116            } else {
117                Log.i(TAG, "Detected that extraction must be performed.");
118                files = performExtractions(sourceApk, dexDir);
119                putStoredApkInfo(context, getTimeStamp(sourceApk), currentCrc, files.size() + 1);
120            }
121        } finally {
122            if (cacheLock != null) {
123                try {
124                    cacheLock.release();
125                } catch (IOException e) {
126                    Log.e(TAG, "Failed to release lock on " + lockFile.getPath());
127                    // Exception while releasing the lock is bad, we want to report it, but not at
128                    // the price of overriding any already pending exception.
129                    releaseLockException = e;
130                }
131            }
132            if (lockChannel != null) {
133                closeQuietly(lockChannel);
134            }
135            closeQuietly(lockRaf);
136        }
137
138        if (releaseLockException != null) {
139            throw releaseLockException;
140        }
141
142        Log.i(TAG, "load found " + files.size() + " secondary dex files");
143        return files;
144    }
145
146    /**
147     * Load previously extracted secondary dex files. Should be called only while owning the lock on
148     * {@link #LOCK_FILENAME}.
149     */
150    private static List<File> loadExistingExtractions(Context context, File sourceApk, File dexDir)
151            throws IOException {
152        Log.i(TAG, "loading existing secondary dex files");
153
154        final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
155        int totalDexNumber = getMultiDexPreferences(context).getInt(KEY_DEX_NUMBER, 1);
156        final List<File> files = new ArrayList<File>(totalDexNumber);
157
158        for (int secondaryNumber = 2; secondaryNumber <= totalDexNumber; secondaryNumber++) {
159            String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
160            File extractedFile = new File(dexDir, fileName);
161            if (extractedFile.isFile()) {
162                files.add(extractedFile);
163                if (!verifyZipFile(extractedFile)) {
164                    Log.i(TAG, "Invalid zip file: " + extractedFile);
165                    throw new IOException("Invalid ZIP file.");
166                }
167            } else {
168                throw new IOException("Missing extracted secondary dex file '" +
169                        extractedFile.getPath() + "'");
170            }
171        }
172
173        return files;
174    }
175
176
177    /**
178     * Compare current archive and crc with values stored in {@link SharedPreferences}. Should be
179     * called only while owning the lock on {@link #LOCK_FILENAME}.
180     */
181    private static boolean isModified(Context context, File archive, long currentCrc) {
182        SharedPreferences prefs = getMultiDexPreferences(context);
183        return (prefs.getLong(KEY_TIME_STAMP, NO_VALUE) != getTimeStamp(archive))
184                || (prefs.getLong(KEY_CRC, NO_VALUE) != currentCrc);
185    }
186
187    private static long getTimeStamp(File archive) {
188        long timeStamp = archive.lastModified();
189        if (timeStamp == NO_VALUE) {
190            // never return NO_VALUE
191            timeStamp--;
192        }
193        return timeStamp;
194    }
195
196
197    private static long getZipCrc(File archive) throws IOException {
198        long computedValue = ZipUtil.getZipCrc(archive);
199        if (computedValue == NO_VALUE) {
200            // never return NO_VALUE
201            computedValue--;
202        }
203        return computedValue;
204    }
205
206    private static List<File> performExtractions(File sourceApk, File dexDir)
207            throws IOException {
208
209        final String extractedFilePrefix = sourceApk.getName() + EXTRACTED_NAME_EXT;
210
211        // Ensure that whatever deletions happen in prepareDexDir only happen if the zip that
212        // contains a secondary dex file in there is not consistent with the latest apk.  Otherwise,
213        // multi-process race conditions can cause a crash loop where one process deletes the zip
214        // while another had created it.
215        prepareDexDir(dexDir, extractedFilePrefix);
216
217        List<File> files = new ArrayList<File>();
218
219        final ZipFile apk = new ZipFile(sourceApk);
220        try {
221
222            int secondaryNumber = 2;
223
224            ZipEntry dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
225            while (dexFile != null) {
226                String fileName = extractedFilePrefix + secondaryNumber + EXTRACTED_SUFFIX;
227                File extractedFile = new File(dexDir, fileName);
228                files.add(extractedFile);
229
230                Log.i(TAG, "Extraction is needed for file " + extractedFile);
231                int numAttempts = 0;
232                boolean isExtractionSuccessful = false;
233                while (numAttempts < MAX_EXTRACT_ATTEMPTS && !isExtractionSuccessful) {
234                    numAttempts++;
235
236                    // Create a zip file (extractedFile) containing only the secondary dex file
237                    // (dexFile) from the apk.
238                    extract(apk, dexFile, extractedFile, extractedFilePrefix);
239
240                    // Verify that the extracted file is indeed a zip file.
241                    isExtractionSuccessful = verifyZipFile(extractedFile);
242
243                    // Log the sha1 of the extracted zip file
244                    Log.i(TAG, "Extraction " + (isExtractionSuccessful ? "success" : "failed") +
245                            " - length " + extractedFile.getAbsolutePath() + ": " +
246                            extractedFile.length());
247                    if (!isExtractionSuccessful) {
248                        // Delete the extracted file
249                        extractedFile.delete();
250                        if (extractedFile.exists()) {
251                            Log.w(TAG, "Failed to delete corrupted secondary dex '" +
252                                    extractedFile.getPath() + "'");
253                        }
254                    }
255                }
256                if (!isExtractionSuccessful) {
257                    throw new IOException("Could not create zip file " +
258                            extractedFile.getAbsolutePath() + " for secondary dex (" +
259                            secondaryNumber + ")");
260                }
261                secondaryNumber++;
262                dexFile = apk.getEntry(DEX_PREFIX + secondaryNumber + DEX_SUFFIX);
263            }
264        } finally {
265            try {
266                apk.close();
267            } catch (IOException e) {
268                Log.w(TAG, "Failed to close resource", e);
269            }
270        }
271
272        return files;
273    }
274
275    /**
276     * Save {@link SharedPreferences}. Should be called only while owning the lock on
277     * {@link #LOCK_FILENAME}.
278     */
279    private static void putStoredApkInfo(Context context, long timeStamp, long crc,
280            int totalDexNumber) {
281        SharedPreferences prefs = getMultiDexPreferences(context);
282        SharedPreferences.Editor edit = prefs.edit();
283        edit.putLong(KEY_TIME_STAMP, timeStamp);
284        edit.putLong(KEY_CRC, crc);
285        edit.putInt(KEY_DEX_NUMBER, totalDexNumber);
286        /* Use commit() and not apply() as advised by the doc because we need synchronous writing of
287         * the editor content and apply is doing an "asynchronous commit to disk".
288         */
289        edit.commit();
290    }
291
292    /**
293     * Get the MuliDex {@link SharedPreferences} for the current application. Should be called only
294     * while owning the lock on {@link #LOCK_FILENAME}.
295     */
296    private static SharedPreferences getMultiDexPreferences(Context context) {
297        return context.getSharedPreferences(PREFS_FILE,
298                Build.VERSION.SDK_INT < 11 /* Build.VERSION_CODES.HONEYCOMB */
299                        ? Context.MODE_PRIVATE
300                        : Context.MODE_PRIVATE | 0x0004 /* Context.MODE_MULTI_PROCESS */);
301    }
302
303    /**
304     * This removes old files.
305     */
306    private static void prepareDexDir(File dexDir, final String extractedFilePrefix) {
307        FileFilter filter = new FileFilter() {
308
309            @Override
310            public boolean accept(File pathname) {
311                String name = pathname.getName();
312                return !(name.startsWith(extractedFilePrefix)
313                        || name.equals(LOCK_FILENAME));
314            }
315        };
316        File[] files = dexDir.listFiles(filter);
317        if (files == null) {
318            Log.w(TAG, "Failed to list secondary dex dir content (" + dexDir.getPath() + ").");
319            return;
320        }
321        for (File oldFile : files) {
322            Log.i(TAG, "Trying to delete old file " + oldFile.getPath() + " of size " +
323                    oldFile.length());
324            if (!oldFile.delete()) {
325                Log.w(TAG, "Failed to delete old file " + oldFile.getPath());
326            } else {
327                Log.i(TAG, "Deleted old file " + oldFile.getPath());
328            }
329        }
330    }
331
332    private static void extract(ZipFile apk, ZipEntry dexFile, File extractTo,
333            String extractedFilePrefix) throws IOException, FileNotFoundException {
334
335        InputStream in = apk.getInputStream(dexFile);
336        ZipOutputStream out = null;
337        File tmp = File.createTempFile(extractedFilePrefix, EXTRACTED_SUFFIX,
338                extractTo.getParentFile());
339        Log.i(TAG, "Extracting " + tmp.getPath());
340        try {
341            out = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(tmp)));
342            try {
343                ZipEntry classesDex = new ZipEntry("classes.dex");
344                // keep zip entry time since it is the criteria used by Dalvik
345                classesDex.setTime(dexFile.getTime());
346                out.putNextEntry(classesDex);
347
348                byte[] buffer = new byte[BUFFER_SIZE];
349                int length = in.read(buffer);
350                while (length != -1) {
351                    out.write(buffer, 0, length);
352                    length = in.read(buffer);
353                }
354                out.closeEntry();
355            } finally {
356                out.close();
357            }
358            Log.i(TAG, "Renaming to " + extractTo.getPath());
359            if (!tmp.renameTo(extractTo)) {
360                throw new IOException("Failed to rename \"" + tmp.getAbsolutePath() +
361                        "\" to \"" + extractTo.getAbsolutePath() + "\"");
362            }
363        } finally {
364            closeQuietly(in);
365            tmp.delete(); // return status ignored
366        }
367    }
368
369    /**
370     * Returns whether the file is a valid zip file.
371     */
372    static boolean verifyZipFile(File file) {
373        try {
374            ZipFile zipFile = new ZipFile(file);
375            try {
376                zipFile.close();
377                return true;
378            } catch (IOException e) {
379                Log.w(TAG, "Failed to close zip file: " + file.getAbsolutePath());
380            }
381        } catch (ZipException ex) {
382            Log.w(TAG, "File " + file.getAbsolutePath() + " is not a valid zip file.", ex);
383        } catch (IOException ex) {
384            Log.w(TAG, "Got an IOException trying to open zip file: " + file.getAbsolutePath(), ex);
385        }
386        return false;
387    }
388
389    /**
390     * Closes the given {@code Closeable}. Suppresses any IO exceptions.
391     */
392    private static void closeQuietly(Closeable closeable) {
393        try {
394            closeable.close();
395        } catch (IOException e) {
396            Log.w(TAG, "Failed to close resource", e);
397        }
398    }
399}
400