/* * 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; import com.android.sdklib.SdkConstants; import com.android.sdklib.SdkManager; import com.android.sdklib.repository.RepoConstants; import org.apache.commons.compress.archivers.zip.ZipArchiveEntry; import org.apache.commons.compress.archivers.zip.ZipFile; 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.net.URL; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Enumeration; import java.util.HashSet; import java.util.Properties; import java.util.Set; /** * Performs the work of installing a given {@link Archive}. */ public class ArchiveInstaller { public static final int NUM_MONITOR_INC = 100; /** * Install this {@link ArchiveInstaller}s. * The archive will be skipped if it is incompatible. * * @return True if the archive was installed, false otherwise. */ public boolean install(Archive archive, String osSdkRoot, boolean forceHttp, SdkManager sdkManager, ITaskMonitor monitor) { Package pkg = archive.getParentPackage(); File archiveFile = null; String name = pkg.getShortDescription(); if (pkg instanceof ExtraPackage && !((ExtraPackage) pkg).isPathValid()) { monitor.setResult("Skipping %1$s: %2$s is not a valid install path.", name, ((ExtraPackage) pkg).getPath()); return false; } if (archive.isLocal()) { // This should never happen. monitor.setResult("Skipping already installed archive: %1$s for %2$s", name, archive.getOsDescription()); return false; } if (!archive.isCompatible()) { monitor.setResult("Skipping incompatible archive: %1$s for %2$s", name, archive.getOsDescription()); return false; } archiveFile = downloadFile(archive, osSdkRoot, monitor, forceHttp); if (archiveFile != null) { // Unarchive calls the pre/postInstallHook methods. if (unarchive(archive, osSdkRoot, archiveFile, sdkManager, monitor)) { monitor.setResult("Installed %1$s", name); // Delete the temp archive if it exists, only on success OsHelper.deleteFileOrFolder(archiveFile); return true; } } return false; } /** * Downloads an archive and returns the temp file with it. * Caller is responsible with deleting the temp file when done. */ private File downloadFile(Archive archive, String osSdkRoot, ITaskMonitor monitor, boolean forceHttp) { String name = archive.getParentPackage().getShortDescription(); String desc = String.format("Downloading %1$s", name); monitor.setDescription(desc); monitor.setResult(desc); 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.setResult("Internal error: no source for archive %1$s", name); 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 (!tmpFolder.isDirectory()) { if (tmpFolder.isFile()) { OsHelper.deleteFileOrFolder(tmpFolder); } if (!tmpFolder.mkdirs()) { monitor.setResult("Failed to create directory %1$s", tmpFolder.getPath()); return null; } } File tmpFile = new File(tmpFolder, base); // if the file exists, check its checksum & size. Use it if complete if (tmpFile.exists()) { if (tmpFile.length() == archive.getSize()) { String chksum = ""; 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 tmpFile; } } // Existing file is either of different size or content. // TODO: continue download when we support continue mode. // Right now, let's simply remove the file and start over. OsHelper.deleteFileOrFolder(tmpFile); } if (fetchUrl(archive, tmpFile, link, desc, monitor)) { // Fetching was successful, let's use this file. return tmpFile; } else { // Delete the temp file if we aborted the download // TODO: disable this when we want to support partial downloads. OsHelper.deleteFileOrFolder(tmpFile); 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.setResult("File not found: %1$s", e.getMessage()); } catch (Exception e) { monitor.setResult(e.getMessage()); } 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}. */ private boolean fetchUrl(Archive archive, File tmpFile, String urlString, String description, ITaskMonitor monitor) { URL url; description += " (%1$d%%, %2$.0f KiB/s, %3$d %4$s left)"; FileOutputStream os = null; InputStream is = null; try { url = new URL(urlString); is = url.openStream(); os = new FileOutputStream(tmpFile); MessageDigest digester = archive.getChecksumType().getMessageDigest(); byte[] buf = new byte[65536]; int n; long total = 0; long size = archive.getSize(); 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); 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(description, percent, speed, timeLeft, timeUnit); } nextMs = timeMs + 1000; // update every second } if (monitor.isCancelRequested()) { monitor.setResult("Download aborted by user at %1$d bytes.", total); return false; } } if (total != size) { monitor.setResult("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.setResult("Download finished with wrong checksum. Expected %1$s, got %2$s.", expected, actual); return false; } return true; } catch (FileNotFoundException e) { // The FNF message is just the URL. Make it a bit more useful. monitor.setResult("File not found: %1$s", e.getMessage()); } catch (Exception e) { monitor.setResult(e.getMessage()); } finally { if (os != null) { try { os.close(); } catch (IOException e) { // pass } } if (is != null) { try { is.close(); } catch (IOException e) { // pass } } } return false; } /** * Install the given archive in the given folder. */ private boolean unarchive(Archive archive, String osSdkRoot, File archiveFile, SdkManager sdkManager, ITaskMonitor monitor) { boolean success = false; Package pkg = archive.getParentPackage(); String pkgName = pkg.getShortDescription(); String pkgDesc = String.format("Installing %1$s", pkgName); monitor.setDescription(pkgDesc); monitor.setResult(pkgDesc); // 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.setResult("Failed to compute installation directory for %1$s.", pkgName); return false; } if (!pkg.preInstallHook(archive, monitor, osSdkRoot, destFolder)) { monitor.setResult("Skipping archive: %1$s", pkgName); return false; } // -1- move old folder. if (destFolder.exists()) { // 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.setResult("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.setResult("Failed to rename directory %1$s to %2$s.", destFolder.getPath(), oldDestFolder.getPath()); if (SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_WINDOWS) { String 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()); if (monitor.displayPrompt("SDK Manager: failed to install", msg)) { // loop, trying to rename the temp dir into the destination continue; } else { return false; } } } break; } } assert !destFolder.exists(); // -2- Unzip new content directly in place. if (!destFolder.mkdirs()) { monitor.setResult("Failed to create directory %1$s", destFolder.getPath()); return false; } if (!unzipFolder(archiveFile, archive.getSize(), destFolder, pkgDesc, monitor)) { return false; } if (!generateSourceProperties(archive, destFolder)) { monitor.setResult("Failed to generate source.properties in directory %1$s", destFolder.getPath()); return false; } success = true; pkg.postInstallHook(archive, 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(archive, monitor, null /*installDir*/); } // Cleanup if the unzip folder is still set. OsHelper.deleteFileOrFolder(oldDestFolder); } } /** * 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 (oldDir.renameTo(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") private boolean unzipFolder(File archiveFile, long compressedSize, File unzipDestFolder, String description, ITaskMonitor monitor) { description += " (%1$d%%)"; ZipFile zipFile = null; try { zipFile = new ZipFile(archiveFile); // figure if we'll need to set the unix permissions boolean usingUnixPerm = SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_DARWIN || SdkConstants.CURRENT_PLATFORM == SdkConstants.PLATFORM_LINUX; // 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). However we want to use our top-level // directory so we drop the first segment of the path name. int pos = name.indexOf('/'); if (pos < 0 || pos == name.length() - 1) { continue; } else { 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 (!destFile.isDirectory() && !destFile.mkdirs()) { monitor.setResult("Failed to create temp directory %1$s", destFile.getPath()); return false; } continue; } else if (name.indexOf('/') != -1) { // Otherwise it's a file in a sub-directory. // Make sure the parent directory has been created. File parentDir = destFile.getParentFile(); if (!parentDir.isDirectory()) { if (!parentDir.mkdirs()) { monitor.setResult("Failed to create temp directory %1$s", parentDir.getPath()); return false; } } } FileOutputStream fos = null; try { fos = new FileOutputStream(destFile); int n; InputStream entryContent = zipFile.getInputStream(entry); while ((n = entryContent.read(buf)) != -1) { if (n > 0) { fos.write(buf, 0, n); } } } finally { if (fos != null) { fos.close(); } } // if needed set the permissions. if (usingUnixPerm && destFile.isFile()) { // get the mode and test if it contains the executable bit int mode = entry.getUnixMode(); if ((mode & 0111) != 0) { OsHelper.setExecutablePermission(destFile); } } // 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(description, percent); lastPercent = percent; } if (monitor.isCancelRequested()) { return false; } } return true; } catch (IOException e) { monitor.setResult("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 (!baseTempFolder.isDirectory()) { if (baseTempFolder.isFile()) { OsHelper.deleteFileOrFolder(baseTempFolder); } if (!baseTempFolder.mkdirs()) { 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 (!folder.exists()) { 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. */ private boolean generateSourceProperties(Archive archive, File unzipDestFolder) { Properties props = new Properties(); archive.saveProperties(props); Package pkg = archive.getParentPackage(); if (pkg != null) { pkg.saveProperties(props); } FileOutputStream fos = null; try { File f = new File(unzipDestFolder, SdkConstants.FN_SOURCE_PROP); fos = new FileOutputStream(f); props.store( fos, "## Android Tool: Source of this archive."); //$NON-NLS-1$ return true; } catch (IOException e) { e.printStackTrace(); } finally { if (fos != null) { try { fos.close(); } catch (IOException e) { } } } 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 = srcFolder.listFiles(); if (srcFiles == null) { // Source does not exist. That is quite odd. return false; } if (destFolder.isFile()) { if (!destFolder.delete()) { // 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 (!destFolder.isDirectory()) { destFolder.mkdirs(); } // 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. File[] files = destFolder.listFiles(); Set<File> destDirs = new HashSet<File>(); Set<File> destFiles = new HashSet<File>(); for (File f : files) { if (f.isDirectory()) { destDirs.add(f); } else { destFiles.add(f); } } // First restore all source directories. for (File dir : srcFiles) { if (dir.isDirectory()) { 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) { OsHelper.deleteFileOrFolder(dir); } // Copy any source files over to the destination. for (File file : srcFiles) { if (file.isFile()) { File f = new File(destFolder, file.getName()); destFiles.remove(f); if (!OsHelper.copyFile(file, f)) { result = false; } } } // Remove any remaining files not processed above. for (File file : destFiles) { OsHelper.deleteFileOrFolder(file); } return result; } }