blob: ab57a3a294e03bacf7d1c3cecad1d8d173158d0e [file] [log] [blame]
/*
* Copyright (C) 2015 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.tools.idea.sdk.remote.internal.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.io.FileOp;
import com.android.sdklib.io.IFileOp;
import com.android.sdklib.repository.local.LocalPkgInfo;
import com.android.tools.idea.sdk.remote.RemotePkgInfo;
import com.android.tools.idea.sdk.remote.internal.CanceledByUserException;
import com.android.tools.idea.sdk.remote.internal.DownloadCache;
import com.android.tools.idea.sdk.remote.internal.ITaskMonitor;
import com.android.tools.idea.sdk.remote.internal.sources.RepoConstants;
import com.android.tools.idea.sdk.remote.internal.sources.SdkSource;
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.*;
import java.net.HttpURLConnection;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.regex.Pattern;
/**
* Performs the work of installing a given {@link Archive}.
*/
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();
}
/**
* 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();
RemotePkgInfo pkg = newArchive.getParentPackage();
String name = pkg.getShortDescription();
// 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
RemotePkgInfo 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.length() > 0) {
headers.add(new BasicHeader(HttpHeaders.IF_MATCH, etag));
}
else if (lastMod != null && lastMod.length() > 0) {
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, HttpURLConnection> result = cache.openDirectUrl(urlString, resumeHeaders, monitor);
is = result.getFirst();
HttpURLConnection connection = result.getSecond();
int status = connection.getResponseCode();
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));
String etag = connection.getHeaderField(HttpHeaders.ETAG);
if (etag != null) {
props.setProperty(HttpHeaders.ETAG, etag);
}
String lastModified = connection.getHeaderField(HttpHeaders.LAST_MODIFIED);
if (lastModified != null) {
props.setProperty(HttpHeaders.LAST_MODIFIED, lastModified);
}
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();
RemotePkgInfo 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.
LocalPkgInfo oldArchive = archiveInfo.getReplaced();
if (oldArchive != null) {
File oldFolder = oldArchive.getLocalDir();
if (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.
* <p/>
* 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();
RemotePkgInfo 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);
RemotePkgInfo 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;
}
}