/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.sdklib.internal.repository.archives; import com.android.SdkConstants; import com.android.annotations.Nullable; import com.android.annotations.VisibleForTesting; import com.android.annotations.VisibleForTesting.Visibility; import com.android.sdklib.SdkManager; import com.android.sdklib.internal.repository.CanceledByUserException; import com.android.sdklib.internal.repository.DownloadCache; import com.android.sdklib.internal.repository.ITaskMonitor; import com.android.sdklib.internal.repository.packages.Package; import com.android.sdklib.internal.repository.sources.SdkSource; import com.android.sdklib.io.FileOp; import com.android.sdklib.io.IFileOp; import com.android.sdklib.repository.RepoConstants; import com.android.utils.GrabProcessOutput; import com.android.utils.GrabProcessOutput.IProcessOutput; import com.android.utils.GrabProcessOutput.Wait; import com.android.utils.Pair; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; import org.apache.http.Header; import org.apache.http.HttpHeaders; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.message.BasicHeader; import java.io.EOFException; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Arrays; import java.util.Enumeration; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.regex.Pattern; /** * Performs the work of installing a given {@link Archive}. * @deprecated * com.android.sdklib.internal.repository has moved into Studio as * com.android.tools.idea.sdk.remote.internal. */ @Deprecated public class ArchiveInstaller { private static final String PROP_STATUS_CODE = "StatusCode"; //$NON-NLS-1$ public static final String ENV_VAR_IGNORE_COMPAT = "ANDROID_SDK_IGNORE_COMPAT"; //$NON-NLS-1$ public static final int NUM_MONITOR_INC = 100; /** The current {@link FileOp} to use. Never null. */ private final IFileOp mFileOp; /** * Generates an {@link ArchiveInstaller} that relies on the default {@link FileOp}. */ public ArchiveInstaller() { mFileOp = new FileOp(); } /** * Generates an {@link ArchiveInstaller} that relies on the given {@link FileOp}. * * @param fileUtils An alternate version of {@link FileOp} to use for file operations. */ protected ArchiveInstaller(IFileOp fileUtils) { mFileOp = fileUtils; } /** Returns current {@link FileOp} to use. Never null. */ protected IFileOp getFileOp() { return mFileOp; } /** * Install this {@link ArchiveReplacement}s. * A "replacement" is composed of the actual new archive to install * (c.f. {@link ArchiveReplacement#getNewArchive()} and an <em>optional</em> * archive being replaced (c.f. {@link ArchiveReplacement#getReplaced()}. * In the case of a new install, the later should be null. * <p/> * The new archive to install will be skipped if it is incompatible. * * @return True if the archive was installed, false otherwise. */ public boolean install(ArchiveReplacement archiveInfo, String osSdkRoot, boolean forceHttp, SdkManager sdkManager, DownloadCache cache, ITaskMonitor monitor) { Archive newArchive = archiveInfo.getNewArchive(); Package pkg = newArchive.getParentPackage(); String name = pkg.getShortDescription(); if (newArchive.isLocal()) { // This should never happen. monitor.log("Skipping already installed archive: %1$s for %2$s", name, newArchive.getOsDescription()); return false; } // In detail mode, give us a way to force install of incompatible archives. boolean checkIsCompatible = System.getenv(ENV_VAR_IGNORE_COMPAT) == null; if (checkIsCompatible && !newArchive.isCompatible()) { monitor.log("Skipping incompatible archive: %1$s for %2$s", name, newArchive.getOsDescription()); return false; } Pair<File, File> files = downloadFile(newArchive, osSdkRoot, cache, monitor, forceHttp); File tmpFile = files == null ? null : files.getFirst(); File propsFile = files == null ? null : files.getSecond(); if (tmpFile != null) { // Unarchive calls the pre/postInstallHook methods. if (unarchive(archiveInfo, osSdkRoot, tmpFile, sdkManager, monitor)) { monitor.log("Installed %1$s", name); // Delete the temp archive if it exists, only on success mFileOp.deleteFileOrFolder(tmpFile); mFileOp.deleteFileOrFolder(propsFile); return true; } } return false; } /** * Downloads an archive and returns the temp file with it. * Caller is responsible with deleting the temp file when done. */ @VisibleForTesting(visibility=Visibility.PRIVATE) protected Pair<File, File> downloadFile(Archive archive, String osSdkRoot, DownloadCache cache, ITaskMonitor monitor, boolean forceHttp) { String pkgName = archive.getParentPackage().getShortDescription(); monitor.setDescription("Downloading %1$s", pkgName); monitor.log("Downloading %1$s", pkgName); String link = archive.getUrl(); if (!link.startsWith("http://") //$NON-NLS-1$ && !link.startsWith("https://") //$NON-NLS-1$ && !link.startsWith("ftp://")) { //$NON-NLS-1$ // Make the URL absolute by prepending the source Package pkg = archive.getParentPackage(); SdkSource src = pkg.getParentSource(); if (src == null) { monitor.logError("Internal error: no source for archive %1$s", pkgName); return null; } // take the URL to the repository.xml and remove the last component // to get the base String repoXml = src.getUrl(); int pos = repoXml.lastIndexOf('/'); String base = repoXml.substring(0, pos + 1); link = base + link; } if (forceHttp) { link = link.replaceAll("https://", "http://"); //$NON-NLS-1$ //$NON-NLS-2$ } // Get the basename of the file we're downloading, i.e. the last component // of the URL int pos = link.lastIndexOf('/'); String base = link.substring(pos + 1); // Rather than create a real temp file in the system, we simply use our // temp folder (in the SDK base folder) and use the archive name for the // download. This allows us to reuse or continue downloads. File tmpFolder = getTempFolder(osSdkRoot); if (!mFileOp.isDirectory(tmpFolder)) { if (mFileOp.isFile(tmpFolder)) { mFileOp.deleteFileOrFolder(tmpFolder); } if (!mFileOp.mkdirs(tmpFolder)) { monitor.logError("Failed to create directory %1$s", tmpFolder.getPath()); return null; } } File tmpFile = new File(tmpFolder, base); // property file were we'll keep partial/resume information for reuse. File propsFile = new File(tmpFolder, base + ".inf"); //$NON-NLS-1$ // if the file exists, check its checksum & size. Use it if complete if (mFileOp.exists(tmpFile)) { if (mFileOp.length(tmpFile) == archive.getSize()) { String chksum = ""; //$NON-NLS-1$ try { chksum = fileChecksum(archive.getChecksumType().getMessageDigest(), tmpFile, monitor); } catch (NoSuchAlgorithmException e) { // Ignore. } if (chksum.equalsIgnoreCase(archive.getChecksum())) { // File is good, let's use it. return Pair.of(tmpFile, propsFile); } else { // The file has the right size but the wrong content. // Just remove it and this will trigger a full download below. mFileOp.deleteFileOrFolder(tmpFile); } } } Header[] resumeHeaders = preparePartialDownload(archive, tmpFile, propsFile); if (fetchUrl(archive, resumeHeaders, tmpFile, propsFile, link, pkgName, cache, monitor)) { // Fetching was successful, let's use this file. return Pair.of(tmpFile, propsFile); } return null; } /** * Prepares to do a partial/resume download. * * @param archive The archive we're trying to download. * @param tmpFile The destination file to download (e.g. something.zip) * @param propsFile A properties file generated by the last partial download (e.g. .zip.inf) * @return Null in case we should perform a full download, or a set of headers * to resume a partial download. */ private Header[] preparePartialDownload(Archive archive, File tmpFile, File propsFile) { // We need both the destination file and its properties to do a resume. if (mFileOp.isFile(tmpFile) && mFileOp.isFile(propsFile)) { // The caller already checked the case were the destination file has the // right size _and_ checksum, so we know at this point one of them is wrong // here. // We can obviously only resume a file if its size is smaller than expected. if (mFileOp.length(tmpFile) < archive.getSize()) { Properties props = mFileOp.loadProperties(propsFile); List<Header> headers = new ArrayList<Header>(2); headers.add(new BasicHeader(HttpHeaders.RANGE, String.format("bytes=%d-", mFileOp.length(tmpFile)))); // Don't use the properties if there's not at least a 200 or 206 code from // the last download. int status = 0; try { status = Integer.parseInt(props.getProperty(PROP_STATUS_CODE)); } catch (Exception ignore) {} if (status == HttpStatus.SC_OK || status == HttpStatus.SC_PARTIAL_CONTENT) { // Do we have an ETag and/or a Last-Modified? String etag = props.getProperty(HttpHeaders.ETAG); String lastMod = props.getProperty(HttpHeaders.LAST_MODIFIED); if (etag != null && !etag.isEmpty()) { headers.add(new BasicHeader(HttpHeaders.IF_MATCH, etag)); } else if (lastMod != null && !lastMod.isEmpty()) { headers.add(new BasicHeader(HttpHeaders.IF_MATCH, lastMod)); } return headers.toArray(new Header[headers.size()]); } } } // Existing file is either of different size or content. // Remove the existing file and request a full download. mFileOp.deleteFileOrFolder(tmpFile); mFileOp.deleteFileOrFolder(propsFile); return null; } /** * Computes the SHA-1 checksum of the content of the given file. * Returns an empty string on error (rather than null). */ private String fileChecksum(MessageDigest digester, File tmpFile, ITaskMonitor monitor) { InputStream is = null; try { is = new FileInputStream(tmpFile); byte[] buf = new byte[65536]; int n; while ((n = is.read(buf)) >= 0) { if (n > 0) { digester.update(buf, 0, n); } } return getDigestChecksum(digester); } catch (FileNotFoundException e) { // The FNF message is just the URL. Make it a bit more useful. monitor.logError("File not found: %1$s", e.getMessage()); } catch (Exception e) { monitor.logError("%1$s", e.getMessage()); //$NON-NLS-1$ } finally { if (is != null) { try { is.close(); } catch (IOException e) { // pass } } } return ""; //$NON-NLS-1$ } /** * Returns the SHA-1 from a {@link MessageDigest} as an hex string * that can be compared with {@link Archive#getChecksum()}. */ private String getDigestChecksum(MessageDigest digester) { int n; // Create an hex string from the digest byte[] digest = digester.digest(); n = digest.length; String hex = "0123456789abcdef"; //$NON-NLS-1$ char[] hexDigest = new char[n * 2]; for (int i = 0; i < n; i++) { int b = digest[i] & 0x0FF; hexDigest[i*2 + 0] = hex.charAt(b >>> 4); hexDigest[i*2 + 1] = hex.charAt(b & 0x0f); } return new String(hexDigest); } /** * Actually performs the download. * Also computes the SHA1 of the file on the fly. * <p/> * Success is defined as downloading as many bytes as was expected and having the same * SHA1 as expected. Returns true on success or false if any of those checks fail. * <p/> * Increments the monitor by {@link #NUM_MONITOR_INC}. * * @param archive The archive we're trying to download. * @param resumeHeaders The headers to use for a partial resume, or null when fetching * a whole new file. * @param tmpFile The destination file to download (e.g. something.zip) * @param propsFile A properties file generated by the last partial download (e.g. .zip.inf) * @param urlString The URL as a string * @param pkgName The archive's package name, used for progress output. * @param cache The {@link DownloadCache} instance to use. * @param monitor The monitor to output the progress and errors. * @return True if we fetched the file successfully. * False if the download failed or was aborted. */ private boolean fetchUrl(Archive archive, Header[] resumeHeaders, File tmpFile, File propsFile, String urlString, String pkgName, DownloadCache cache, ITaskMonitor monitor) { FileOutputStream os = null; InputStream is = null; int inc_remain = NUM_MONITOR_INC; try { Pair<InputStream, HttpResponse> result = cache.openDirectUrl(urlString, resumeHeaders, monitor); is = result.getFirst(); HttpResponse resp = result.getSecond(); int status = resp.getStatusLine().getStatusCode(); if (status == HttpStatus.SC_NOT_FOUND) { throw new Exception("URL not found."); } if (is == null) { throw new Exception("No content."); } Properties props = new Properties(); props.setProperty(PROP_STATUS_CODE, Integer.toString(status)); if (resp.containsHeader(HttpHeaders.ETAG)) { props.setProperty(HttpHeaders.ETAG, resp.getFirstHeader(HttpHeaders.ETAG).getValue()); } if (resp.containsHeader(HttpHeaders.LAST_MODIFIED)) { props.setProperty(HttpHeaders.LAST_MODIFIED, resp.getFirstHeader(HttpHeaders.LAST_MODIFIED).getValue()); } try { mFileOp.saveProperties(propsFile, props, "## Android SDK Download."); //$NON-NLS-1$ } catch (IOException ignore) {} // On success, status can be: // - 206 (Partial content), if resumeHeaders is not null (we asked for a partial // download, and we get partial content for that download => we'll need to append // to the existing file.) // - 200 (OK) meaning we're getting whole new content from scratch. This can happen // even if resumeHeaders is not null (typically means the server has a new version // of the file to serve.) In this case we reset the file and write from scratch. boolean append = status == HttpStatus.SC_PARTIAL_CONTENT; if (status != HttpStatus.SC_OK && !(append && resumeHeaders != null)) { throw new Exception(String.format("Unexpected HTTP Status %1$d", status)); } MessageDigest digester = archive.getChecksumType().getMessageDigest(); if (append) { // Seed the digest with the existing content. InputStream temp = null; try { temp = new FileInputStream(tmpFile); byte[] buf = new byte[65536]; int n; while ((n = temp.read(buf)) >= 0) { if (n > 0) { digester.update(buf, 0, n); } } } catch (Exception ignore) { } finally { if (temp != null) { try { temp.close(); } catch (IOException ignore) {} } } } // Open the output stream in append for a resume, or reset for a full download. os = new FileOutputStream(tmpFile, append); byte[] buf = new byte[65536]; int n; long total = 0; long size = archive.getSize(); if (append) { long len = mFileOp.length(tmpFile); int percent = (int) (len * 100 / size); size -= len; monitor.logVerbose( "Resuming %1$s download at %2$d (%3$d%%)", pkgName, len, percent); } long inc = size / NUM_MONITOR_INC; long next_inc = inc; long startMs = System.currentTimeMillis(); long nextMs = startMs + 2000; // start update after 2 seconds while ((n = is.read(buf)) >= 0) { if (n > 0) { os.write(buf, 0, n); digester.update(buf, 0, n); } long timeMs = System.currentTimeMillis(); total += n; if (total >= next_inc) { monitor.incProgress(1); inc_remain--; next_inc += inc; } if (timeMs > nextMs) { long delta = timeMs - startMs; if (total > 0 && delta > 0) { // percent left to download int percent = (int) (100 * total / size); // speed in KiB/s float speed = (float)total / (float)delta * (1000.f / 1024.f); // time left to download the rest at the current KiB/s rate int timeLeft = (speed > 1e-3) ? (int)(((size - total) / 1024.0f) / speed) : 0; String timeUnit = "seconds"; if (timeLeft > 120) { timeUnit = "minutes"; timeLeft /= 60; } monitor.setDescription( "Downloading %1$s (%2$d%%, %3$.0f KiB/s, %4$d %5$s left)", pkgName, percent, speed, timeLeft, timeUnit); } nextMs = timeMs + 1000; // update every second } if (monitor.isCancelRequested()) { monitor.log("Download aborted by user at %1$d bytes.", total); return false; } } if (total != size) { monitor.logError( "Download finished with wrong size. Expected %1$d bytes, got %2$d bytes.", size, total); return false; } // Create an hex string from the digest String actual = getDigestChecksum(digester); String expected = archive.getChecksum(); if (!actual.equalsIgnoreCase(expected)) { monitor.logError("Download finished with wrong checksum. Expected %1$s, got %2$s.", expected, actual); return false; } return true; } catch (CanceledByUserException e) { // HTTP Basic Auth or NTLM login was canceled by user. // Don't output an error in the log. } catch (FileNotFoundException e) { // The FNF message is just the URL. Make it a bit more useful. monitor.logError("URL not found: %1$s", e.getMessage()); } catch (Exception e) { monitor.logError("Download interrupted: %1$s", e.getMessage()); //$NON-NLS-1$ } finally { if (os != null) { try { os.close(); } catch (IOException e) { // pass } } if (is != null) { try { is.close(); } catch (IOException e) { // pass } } if (inc_remain > 0) { monitor.incProgress(inc_remain); } } return false; } /** * Install the given archive in the given folder. */ private boolean unarchive(ArchiveReplacement archiveInfo, String osSdkRoot, File archiveFile, SdkManager sdkManager, ITaskMonitor monitor) { boolean success = false; Archive newArchive = archiveInfo.getNewArchive(); Package pkg = newArchive.getParentPackage(); String pkgName = pkg.getShortDescription(); monitor.setDescription("Installing %1$s", pkgName); monitor.log("Installing %1$s", pkgName); // Ideally we want to always unzip in a temp folder which name depends on the package // type (e.g. addon, tools, etc.) and then move the folder to the destination folder. // If the destination folder exists, it will be renamed and deleted at the very // end if everything succeeded. This provides a nice atomic swap and should leave the // original folder untouched in case something wrong (e.g. program crash) in the // middle of the unzip operation. // // However that doesn't work on Windows, we always end up not being able to move the // new folder. There are actually 2 cases: // A- A process such as a the explorer is locking the *old* folder or a file inside // (e.g. adb.exe) // In this case we really shouldn't be tried to work around it and we need to let // the user know and let it close apps that access that folder. // B- A process is locking the *new* folder. Very often this turns to be a file indexer // or an anti-virus that is busy scanning the new folder that we just unzipped. // // So we're going to change the strategy: // 1- Try to move the old folder to a temp/old folder. This might fail in case of issue A. // Note: for platform-tools, we can try killing adb first. // If it still fails, we do nothing and ask the user to terminate apps that can be // locking that folder. // 2- Once the old folder is out of the way, we unzip the archive directly into the // optimal new location. We no longer unzip it in a temp folder and move it since we // know that's what fails in most of the cases. // 3- If the unzip fails, remove everything and try to restore the old folder by doing // a *copy* in place and not a folder move (which will likely fail too). String pkgKind = pkg.getClass().getSimpleName(); File destFolder = null; File oldDestFolder = null; try { // -0- Compute destination directory and check install pre-conditions destFolder = pkg.getInstallFolder(osSdkRoot, sdkManager); if (destFolder == null) { // this should not seriously happen. monitor.log("Failed to compute installation directory for %1$s.", pkgName); return false; } if (!pkg.preInstallHook(newArchive, monitor, osSdkRoot, destFolder)) { monitor.log("Skipping archive: %1$s", pkgName); return false; } // -1- move old folder. if (mFileOp.exists(destFolder)) { // Create a new temp/old dir if (oldDestFolder == null) { oldDestFolder = getNewTempFolder(osSdkRoot, pkgKind, "old"); //$NON-NLS-1$ } if (oldDestFolder == null) { // this should not seriously happen. monitor.logError("Failed to find a temp directory in %1$s.", osSdkRoot); return false; } // Try to move the current dest dir to the temp/old one. Tell the user if it failed. while(true) { if (!moveFolder(destFolder, oldDestFolder)) { monitor.logError("Failed to rename directory %1$s to %2$s.", destFolder.getPath(), oldDestFolder.getPath()); if (SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_WINDOWS) { boolean tryAgain = true; tryAgain = windowsDestDirLocked(osSdkRoot, destFolder, monitor); if (tryAgain) { // loop, trying to rename the temp dir into the destination continue; } else { return false; } } } break; } } assert !mFileOp.exists(destFolder); // -2- Unzip new content directly in place. if (!mFileOp.mkdirs(destFolder)) { monitor.logError("Failed to create directory %1$s", destFolder.getPath()); return false; } if (!unzipFolder(archiveInfo, archiveFile, destFolder, monitor)) { return false; } if (!generateSourceProperties(newArchive, destFolder)) { monitor.logError("Failed to generate source.properties in directory %1$s", destFolder.getPath()); return false; } // In case of success, if we were replacing an archive // and the older one had a different path, remove it now. Archive oldArchive = archiveInfo.getReplaced(); if (oldArchive != null && oldArchive.isLocal()) { String oldPath = oldArchive.getLocalOsPath(); File oldFolder = oldPath == null ? null : new File(oldPath); if (oldFolder == null && oldArchive.getParentPackage() != null) { oldFolder = oldArchive.getParentPackage().getInstallFolder( osSdkRoot, sdkManager); } if (oldFolder != null && mFileOp.exists(oldFolder) && !oldFolder.equals(destFolder)) { monitor.logVerbose("Removing old archive at %1$s", oldFolder.getAbsolutePath()); mFileOp.deleteFileOrFolder(oldFolder); } } success = true; pkg.postInstallHook(newArchive, monitor, destFolder); return true; } finally { if (!success) { // In case of failure, we try to restore the old folder content. if (oldDestFolder != null) { restoreFolder(oldDestFolder, destFolder); } // We also call the postInstallHool with a null directory to give a chance // to the archive to cleanup after preInstallHook. pkg.postInstallHook(newArchive, monitor, null /*installDir*/); } // Cleanup if the unzip folder is still set. mFileOp.deleteFileOrFolder(oldDestFolder); } } private boolean windowsDestDirLocked( String osSdkRoot, File destFolder, final ITaskMonitor monitor) { String msg = null; assert SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_WINDOWS; File findLockExe = FileOp.append( osSdkRoot, SdkConstants.FD_TOOLS, SdkConstants.FD_LIB, SdkConstants.FN_FIND_LOCK); if (mFileOp.exists(findLockExe)) { try { final StringBuilder result = new StringBuilder(); String command[] = new String[] { findLockExe.getAbsolutePath(), destFolder.getAbsolutePath() }; Process process = Runtime.getRuntime().exec(command); int retCode = GrabProcessOutput.grabProcessOutput( process, Wait.WAIT_FOR_READERS, new IProcessOutput() { @Override public void out(@Nullable String line) { if (line != null) { result.append(line).append("\n"); } } @Override public void err(@Nullable String line) { if (line != null) { monitor.logError("[find_lock] Error: %1$s", line); } } }); if (retCode == 0 && result.length() > 0) { // TODO create a better dialog String found = result.toString().trim(); monitor.logError("[find_lock] Directory locked by %1$s", found); TreeSet<String> apps = new TreeSet<String>(Arrays.asList( found.split(Pattern.quote(";")))); //$NON-NLS-1$ StringBuilder appStr = new StringBuilder(); for (String app : apps) { appStr.append("\n - ").append(app.trim()); //$NON-NLS-1$ } msg = String.format( "-= Warning ! =-\n" + "The following processes: %1$s\n" + "are locking the following directory: \n" + " %2$s\n" + "Please close these applications so that the installation can continue.\n" + "When ready, press YES to try again.", appStr.toString(), destFolder.getPath()); } } catch (Exception e) { monitor.error(e, "[find_lock failed]"); } } if (msg == null) { // Old way: simply display a generic text and let user figure it out. msg = String.format( "-= Warning ! =-\n" + "A folder failed to be moved. On Windows this " + "typically means that a program is using that folder (for " + "example Windows Explorer or your anti-virus software.)\n" + "Please momentarily deactivate your anti-virus software or " + "close any running programs that may be accessing the " + "directory '%1$s'.\n" + "When ready, press YES to try again.", destFolder.getPath()); } boolean tryAgain = monitor.displayPrompt("SDK Manager: failed to install", msg); return tryAgain; } /** * Tries to rename/move a folder. * <p/> * Contract: * <ul> * <li> When we start, oldDir must exist and be a directory. newDir must not exist. </li> * <li> On successful completion, oldDir must not exists. * newDir must exist and have the same content. </li> * <li> On failure completion, oldDir must have the same content as before. * newDir must not exist. </li> * </ul> * <p/> * The simple "rename" operation on a folder can typically fail on Windows for a variety * of reason, in fact as soon as a single process holds a reference on a directory. The * most common case are the Explorer, the system's file indexer, Tortoise SVN cache or * an anti-virus that are busy indexing a new directory having been created. * * @param oldDir The old location to move. It must exist and be a directory. * @param newDir The new location where to move. It must not exist. * @return True if the move succeeded. On failure, we try hard to not have touched the old * directory in order not to loose its content. */ private boolean moveFolder(File oldDir, File newDir) { // This is a simple folder rename that works on Linux/Mac all the time. // // On Windows this might fail if an indexer is busy looking at a new directory // (e.g. right after we unzip our archive), so it fails let's be nice and give // it a bit of time to succeed. for (int i = 0; i < 5; i++) { if (mFileOp.renameTo(oldDir, newDir)) { return true; } try { Thread.sleep(500 /*ms*/); } catch (InterruptedException e) { // ignore } } return false; } /** * Unzips a zip file into the given destination directory. * * The archive file MUST have a unique "root" folder. * This root folder is skipped when unarchiving. */ @SuppressWarnings("unchecked") @VisibleForTesting(visibility=Visibility.PRIVATE) protected boolean unzipFolder( ArchiveReplacement archiveInfo, File archiveFile, File unzipDestFolder, ITaskMonitor monitor) { Archive newArchive = archiveInfo.getNewArchive(); Package pkg = newArchive.getParentPackage(); String pkgName = pkg.getShortDescription(); long compressedSize = newArchive.getSize(); ZipFile zipFile = null; try { zipFile = new ZipFile(archiveFile); // To advance the percent and the progress bar, we don't know the number of // items left to unzip. However we know the size of the archive and the size of // each uncompressed item. The zip file format overhead is negligible so that's // a good approximation. long incStep = compressedSize / NUM_MONITOR_INC; long incTotal = 0; long incCurr = 0; int lastPercent = 0; byte[] buf = new byte[65536]; Enumeration<ZipArchiveEntry> entries = zipFile.getEntries(); while (entries.hasMoreElements()) { ZipArchiveEntry entry = entries.nextElement(); String name = entry.getName(); // ZipFile entries should have forward slashes, but not all Zip // implementations can be expected to do that. name = name.replace('\\', '/'); // Zip entries are always packages in a top-level directory (e.g. docs/index.html). int pos = name.indexOf('/'); if (pos == -1) { // All zip entries should have a root folder. // This zip entry seems located at the root of the zip. // Rather than ignore the file, just place it at the root. } else if (pos == name.length() - 1) { // This is a zip *directory* entry in the form dir/, so essentially // it's the root directory of the SDK. It's safe to ignore that one // since we want to use our own root directory and we'll recreate // root directories as needed. // A direct consequence is that if a malformed archive has multiple // root directories, their content will all be merged together. continue; } else { // This is the expected behavior: the zip entry is in the form root/file // or root/dir/. We want to use our top-level directory so we drop the // first segment of the path name. name = name.substring(pos + 1); } File destFile = new File(unzipDestFolder, name); if (name.endsWith("/")) { //$NON-NLS-1$ // Create directory if it doesn't exist yet. This allows us to create // empty directories. if (!mFileOp.isDirectory(destFile) && !mFileOp.mkdirs(destFile)) { monitor.logError("Failed to create directory %1$s", destFile.getPath()); return false; } continue; } else if (name.indexOf('/') != -1) { // Otherwise it's a file in a sub-directory. // Sanity check: since we're always unzipping in a fresh temp folder // the destination file shouldn't already exist. if (mFileOp.exists(destFile)) { monitor.logVerbose("Duplicate file found: %1$s", name); } // Make sure the parent directory has been created. File parentDir = destFile.getParentFile(); if (!mFileOp.isDirectory(parentDir)) { if (!mFileOp.mkdirs(parentDir)) { monitor.logError("Failed to create directory %1$s", parentDir.getPath()); return false; } } } FileOutputStream fos = null; long remains = entry.getSize(); try { fos = new FileOutputStream(destFile); // Java bug 4040920: do not rely on the input stream EOF and don't // try to read more than the entry's size. InputStream entryContent = zipFile.getInputStream(entry); int n; while (remains > 0 && (n = entryContent.read( buf, 0, (int) Math.min(remains, buf.length))) != -1) { remains -= n; if (n > 0) { fos.write(buf, 0, n); } } } catch (EOFException e) { monitor.logError("Error uncompressing file %s. Size: %d bytes, Unwritten: %d bytes.", entry.getName(), entry.getSize(), remains); throw e; } finally { if (fos != null) { fos.close(); } } pkg.postUnzipFileHook(newArchive, monitor, mFileOp, destFile, entry); // Increment progress bar to match. We update only between files. for(incTotal += entry.getCompressedSize(); incCurr < incTotal; incCurr += incStep) { monitor.incProgress(1); } int percent = (int) (100 * incTotal / compressedSize); if (percent != lastPercent) { monitor.setDescription("Unzipping %1$s (%2$d%%)", pkgName, percent); lastPercent = percent; } if (monitor.isCancelRequested()) { return false; } } return true; } catch (IOException e) { monitor.logError("Unzip failed: %1$s", e.getMessage()); } finally { if (zipFile != null) { try { zipFile.close(); } catch (IOException e) { // pass } } } return false; } /** * Returns an unused temp folder path in the form of osBasePath/temp/prefix.suffixNNN. * <p/> * This does not actually <em>create</em> the folder. It just scan the base path for * a free folder name to use and returns the file to use to reference it. * <p/> * This operation is not atomic so there's no guarantee the folder can't get * created in between. This is however unlikely and the caller can assume the * returned folder does not exist yet. * <p/> * Returns null if no such folder can be found (e.g. if all candidates exist, * which is rather unlikely) or if the base temp folder cannot be created. */ private File getNewTempFolder(String osBasePath, String prefix, String suffix) { File baseTempFolder = getTempFolder(osBasePath); if (!mFileOp.isDirectory(baseTempFolder)) { if (mFileOp.isFile(baseTempFolder)) { mFileOp.deleteFileOrFolder(baseTempFolder); } if (!mFileOp.mkdirs(baseTempFolder)) { return null; } } for (int i = 1; i < 100; i++) { File folder = new File(baseTempFolder, String.format("%1$s.%2$s%3$02d", prefix, suffix, i)); //$NON-NLS-1$ if (!mFileOp.exists(folder)) { return folder; } } return null; } /** * Returns the single fixed "temp" folder used by the SDK Manager. * This folder is always at osBasePath/temp. * <p/> * This does not actually <em>create</em> the folder. */ private File getTempFolder(String osBasePath) { File baseTempFolder = new File(osBasePath, RepoConstants.FD_TEMP); return baseTempFolder; } /** * Generates a source.properties in the destination folder that contains all the infos * relevant to this archive, this package and the source so that we can reload them * locally later. */ @VisibleForTesting(visibility=Visibility.PRIVATE) protected boolean generateSourceProperties(Archive archive, File unzipDestFolder) { // Create a version of Properties that returns a sorted key set. // This is used by Properties#saveProperties and should ensure the // properties are in a stable order. Unit tests rely on this fact. @SuppressWarnings("serial") Properties props = new Properties() { @Override public synchronized Enumeration<Object> keys() { Set<Object> sortedSet = new TreeSet<Object>(keySet()); final Iterator<Object> it = sortedSet.iterator(); return new Enumeration<Object>() { @Override public boolean hasMoreElements() { return it.hasNext(); } @Override public Object nextElement() { return it.next(); } }; } }; archive.saveProperties(props); Package pkg = archive.getParentPackage(); if (pkg != null) { pkg.saveProperties(props); } try { mFileOp.saveProperties( new File(unzipDestFolder, SdkConstants.FN_SOURCE_PROP), props, "## Android Tool: Source of this archive."); //$NON-NLS-1$ return true; } catch (IOException ignore) { return false; } } /** * Recursively restore srcFolder into destFolder by performing a copy of the file * content rather than rename/moves. * * @param srcFolder The source folder to restore. * @param destFolder The destination folder where to restore. * @return True if the folder was successfully restored, false if it was not at all or * only partially restored. */ private boolean restoreFolder(File srcFolder, File destFolder) { boolean result = true; // Process sub-folders first File[] srcFiles = mFileOp.listFiles(srcFolder); if (srcFiles == null) { // Source does not exist. That is quite odd. return false; } if (mFileOp.isFile(destFolder)) { if (!mFileOp.delete(destFolder)) { // There's already a file in there where we want a directory and // we can't delete it. This is rather unexpected. Just give up on // that folder. return false; } } else if (!mFileOp.isDirectory(destFolder)) { mFileOp.mkdirs(destFolder); } // Get all the files and dirs of the current destination. // We are not going to clean up the destination first. // Instead we'll copy over and just remove any remaining files or directories. Set<File> destDirs = new HashSet<File>(); Set<File> destFiles = new HashSet<File>(); File[] files = mFileOp.listFiles(destFolder); if (files != null) { for (File f : files) { if (mFileOp.isDirectory(f)) { destDirs.add(f); } else { destFiles.add(f); } } } // First restore all source directories. for (File dir : srcFiles) { if (mFileOp.isDirectory(dir)) { File d = new File(destFolder, dir.getName()); destDirs.remove(d); if (!restoreFolder(dir, d)) { result = false; } } } // Remove any remaining directories not processed above. for (File dir : destDirs) { mFileOp.deleteFileOrFolder(dir); } // Copy any source files over to the destination. for (File file : srcFiles) { if (mFileOp.isFile(file)) { File f = new File(destFolder, file.getName()); destFiles.remove(f); try { mFileOp.copyFile(file, f); } catch (IOException e) { result = false; } } } // Remove any remaining files not processed above. for (File file : destFiles) { mFileOp.deleteFileOrFolder(file); } return result; } }