blob: b0b2bf54ac7fa74a2ddcf44c6192647e6ea8ac1d [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.repository.impl.installer;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.io.CancellableFileIo;
import com.android.repository.api.DelegatingProgressIndicator;
import com.android.repository.api.Installer;
import com.android.repository.api.PackageOperation;
import com.android.repository.api.ProgressIndicator;
import com.android.repository.api.RepoManager;
import com.android.repository.api.Uninstaller;
import com.android.repository.io.FileOpUtils;
import com.android.repository.util.InstallerUtil;
import com.android.utils.PathUtils;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Lists;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Properties;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* Frameworks for concrete {@link Installer}s and {@link Uninstaller}s that manages creation of temp
* directories, writing package metadata and install status, and resuming in-progress installs.
*/
public abstract class AbstractPackageOperation implements PackageOperation {
/**
* Key used in the properties file for the temporary path.
*/
private static final String PATH_KEY = "path";
/**
* The concrete type of the installer. TODO: do we actually need this?
*/
private static final String CLASSNAME_KEY = "class";
/**
* The filename prefix used to store SDK manager metadata. Directories starting with this prefix should not be scanned for packages.
*/
public static final String METADATA_FILENAME_PREFIX = ".";
/**
* Name of the marker file that's written into the temporary directory when the prepare phase
* has completed successfully.
*/
private static final String PREPARE_COMPLETE_FN = METADATA_FILENAME_PREFIX + "prepareComplete";
/**
* Name of the directory created in the final install location containing data to get the
* install restarted if it stops.
*/
private static final String INSTALL_DATA_FN = METADATA_FILENAME_PREFIX + "installData";
/**
* Name of the directory used as the base for temporary files and located within the repo root.
* We intentionally do not use system temp for downloads due to potentially large download size
* that wouldn't always fit into a system-managed temp directory, but should fit into the SDK
* directory (since this is where the uncompressed package will be installed anyway).
*/
public static final String REPO_TEMP_DIR_FN = METADATA_FILENAME_PREFIX + "temp";
/**
* Name of the directory used as the base for download intermediates which some package
* operations may use to customize their downloader settings.
*/
public static final String DOWNLOAD_INTERMEDIATES_DIR_FN =
METADATA_FILENAME_PREFIX + "downloadIntermediates";
/**
* Prefix used when creating temporary directories.
*/
static final String TEMP_DIR_PREFIX = "PackageOperation";
/**
* Maximal number of temporary directories for package operations.
*/
static final int MAX_PACKAGE_OPERATION_TEMP_DIRS = 100;
/**
* Status of the installer.
*/
private InstallStatus mInstallStatus = InstallStatus.NOT_STARTED;
/**
* Properties written to the final install folder, used to restart the installer if needed.
*/
private Properties mInstallProperties;
private PackageOperation mFallbackOperation;
private final Object mStateChangeLock = new Object();
private enum StartTaskStatus {STARTED, ALREADY_DONE, FAILED}
/** Listeners that will be notified when the status changes. */
private final List<StatusChangeListener> mListeners = Lists.newArrayList();
private final RepoManager mRepoManager;
private DelegatingProgressIndicator mPrepareProgress;
private DelegatingProgressIndicator mCompleteProgress;
protected AbstractPackageOperation(@NonNull RepoManager repoManager) {
mRepoManager = repoManager;
}
/**
* Subclasses should override this to prepare a package for (un)installation, including
* downloading, unzipping, etc. as needed. No modification to the actual SDK should happen
* during this time.
*
* @param installTempPath The dir that should be used for any intermediate processing.
* @param progress For logging and progress display
*/
protected abstract boolean doPrepare(
@NonNull Path installTempPath, @NonNull ProgressIndicator progress);
/**
* Subclasses should implement this to do any install/uninstall completion actions required.
*
* @param installTemp The temporary dir in which we prepared the (un)install. May be {@code
* null} if for example the installer removed the installer properties file, but should not
* be normally.
* @param progress For logging and progress indication.
* @return {@code true} if the operation succeeded, {@code false} otherwise.
* @see #complete(ProgressIndicator)
*/
protected abstract boolean doComplete(
@Nullable Path installTemp, @NonNull ProgressIndicator progress);
/**
* Finds the prepared files using the installer metadata, and calls {@link #doComplete(Path,
* ProgressIndicator)}.
*
* @param progress A {@link ProgressIndicator}, to show install progress and facilitate logging.
* @return {@code true} if the install was successful, {@code false} otherwise.
*/
@Override
public final boolean complete(@NonNull ProgressIndicator progress) {
synchronized (mProgressLock) {
mCompleteProgress = addProgress(progress, mCompleteProgress);
}
StartTaskStatus startResult = startTask(InstallStatus.RUNNING, mCompleteProgress);
if (startResult != StartTaskStatus.STARTED) {
return startResult == StartTaskStatus.ALREADY_DONE;
}
if (mInstallProperties == null) {
try {
mInstallProperties = readInstallProperties(getLocation(mCompleteProgress));
} catch (IOException e) {
// We won't have a temp path, but try to continue anyway
}
}
boolean result = false;
String installTempPath = null;
if (mInstallProperties != null) {
installTempPath = mInstallProperties.getProperty(PATH_KEY);
}
Path installTemp =
installTempPath == null ? null : getLocation(progress).resolve(installTempPath);
try {
// Re-validate the install path, in case something was changed since prepare.
if (!InstallerUtil.checkValidPath(
getLocation(mCompleteProgress), getRepoManager(), mCompleteProgress)) {
return false;
}
result = doComplete(installTemp, mCompleteProgress);
mCompleteProgress.logInfo(String.format("\"%1$s\" complete.", getName()));
} finally {
if (!result && mCompleteProgress.isCanceled()) {
cleanup(mCompleteProgress);
}
result &=
updateStatus(
result ? InstallStatus.COMPLETE : InstallStatus.FAILED,
mCompleteProgress);
if (result && installTemp != null) {
FileOpUtils.deleteFileOrFolder(installTemp);
}
getRepoManager().installEnded(getPackage());
getRepoManager().markLocalCacheInvalid();
}
mCompleteProgress.setFraction(1);
mCompleteProgress.setIndeterminate(false);
mCompleteProgress.logInfo(
String.format("\"%1$s\" %2$s.", getName(), result ? "finished" : "failed"));
return result;
}
@NonNull
private StartTaskStatus startTask(
@NonNull InstallStatus inProgress, @NonNull ProgressIndicator progress) {
boolean alreadyStarted = false;
CompletableFuture<Void> f = new CompletableFuture<>();
synchronized (mStateChangeLock) {
if (mInstallStatus == InstallStatus.FAILED) {
return StartTaskStatus.FAILED;
} else if (mInstallStatus.compareTo(inProgress) > 0) {
return StartTaskStatus.ALREADY_DONE;
} else if (mInstallStatus == inProgress) {
registerStateChangeListener((op, p) -> {
// Complete only if we've moved on. Since the listeners are retrieved outside
// this synchronized block, it's possible for the update to be to the current
// inProgress state rather than away from it.
if (op.getInstallStatus().compareTo(inProgress) > 0) {
f.complete(null);
}
});
alreadyStarted = true;
} else {
// Don't use updateStatus here, since we don't want the listeners to fire in the
// synchronized block.
mInstallStatus = inProgress;
}
}
boolean success;
if (alreadyStarted) {
// Method isn't expected to return while task is in process. Wait for existing one.
try {
f.get();
success = getInstallStatus() != InstallStatus.FAILED;
} catch (InterruptedException | ExecutionException e) {
// Shouldn't happen, but if it does consider us to be failed.
success = false;
}
} else {
// Now fire the listeners for actually starting
success = updateStatus(inProgress, progress);
}
if (!success) {
progress.setFraction(1);
progress.setIndeterminate(false);
progress.logInfo(String.format("\"%1$s\" failed.", getName()));
return StartTaskStatus.FAILED;
}
return alreadyStarted ? StartTaskStatus.ALREADY_DONE : StartTaskStatus.STARTED;
}
/**
* Looks in {@code installPath} for an install properties file and returns the contents if
* found.
*/
@Nullable
private static Properties readInstallProperties(@NonNull Path installPath) throws IOException {
Path metaDir = installPath.resolve(InstallerUtil.INSTALLER_DIR_FN);
Path dataFile = metaDir.resolve(INSTALL_DATA_FN);
if (Files.exists(dataFile)) {
Properties installProperties = new Properties();
try (InputStream inStream = Files.newInputStream(dataFile)) {
installProperties.load(inStream);
return installProperties;
}
}
return null;
}
protected void cleanup(@NonNull ProgressIndicator progress) {
FileOpUtils.deleteFileOrFolder(
getLocation(progress).resolve(InstallerUtil.INSTALLER_DIR_FN));
}
/**
* Writes information used to restore the operation state if needed, then calls {@link
* #doPrepare(Path, ProgressIndicator)}
*
* @param progress A {@link ProgressIndicator}, to show progress and facilitate logging.
* @return {@code true} if the operation succeeded, {@code false} otherwise.
*/
@Override
public final boolean prepare(@NonNull ProgressIndicator progress) {
synchronized (mProgressLock) {
mPrepareProgress = addProgress(progress, mPrepareProgress);
}
StartTaskStatus startResult = startTask(InstallStatus.PREPARING, mPrepareProgress);
if (startResult != StartTaskStatus.STARTED) {
return startResult == StartTaskStatus.ALREADY_DONE;
}
mPrepareProgress.logInfo(String.format("Preparing \"%1$s\".", getName()));
try {
Path dest = getLocation(mPrepareProgress);
mInstallProperties = readOrCreateInstallProperties(dest, mPrepareProgress);
} catch (IOException e) {
mPrepareProgress.logWarning("Failed to read or create install properties file.");
return false;
}
getRepoManager().installBeginning(getPackage(), this);
boolean result = false;
try {
if (!InstallerUtil.checkValidPath(
getLocation(mPrepareProgress), getRepoManager(), mPrepareProgress)) {
return false;
}
Path installTempPath = writeInstallerMetadata(mPrepareProgress);
if (installTempPath == null) {
mPrepareProgress.logInfo(String.format("\"%1$s\" failed.", getName()));
return false;
}
Path prepareCompleteMarker = installTempPath.resolve(PREPARE_COMPLETE_FN);
if (!CancellableFileIo.exists(prepareCompleteMarker)) {
if (doPrepare(installTempPath, mPrepareProgress)) {
Files.createFile(prepareCompleteMarker);
result = updateStatus(InstallStatus.PREPARED, mPrepareProgress);
}
} else {
mPrepareProgress.logInfo("Found existing prepared package.");
result = true;
}
} catch (IOException ignore) {
} finally {
if (!result) {
getRepoManager().installEnded(getPackage());
updateStatus(InstallStatus.FAILED, mPrepareProgress);
// If there was a failure don't clean up the files, so we can continue if requested
if (mPrepareProgress.isCanceled()) {
cleanup(mPrepareProgress);
}
}
}
mPrepareProgress.logInfo(
String.format("\"%1$s\" %2$s.", getName(), result ? "ready" : "failed"));
return result;
}
/**
* Looks in {@code affectedPath} for an install properties file and returns the contents if
* found. If not found, creates and populates it.
*
* @param affectedPath The path on which this operation acts (either to write to or delete from)
* @return The read or created properties.
*/
@NonNull
private Properties readOrCreateInstallProperties(
@NonNull Path affectedPath, @NonNull ProgressIndicator progress) throws IOException {
Properties installProperties = readInstallProperties(affectedPath);
if (installProperties != null && installProperties.containsKey(PATH_KEY)) {
return installProperties;
}
installProperties = new Properties();
Path metaDir = affectedPath.resolve(InstallerUtil.INSTALLER_DIR_FN);
Files.createDirectories(metaDir);
Path dataFile = metaDir.resolve(INSTALL_DATA_FN);
Path installTempPath = getNewPackageOperationTempDir(getRepoManager(), TEMP_DIR_PREFIX);
if (installTempPath == null) {
deleteOrphanedTempDirs(progress);
installTempPath = getNewPackageOperationTempDir(getRepoManager(), TEMP_DIR_PREFIX);
if (installTempPath == null) {
throw new IOException("Failed to create temp path");
}
}
installProperties.put(PATH_KEY, installTempPath.toAbsolutePath().toString());
installProperties.put(CLASSNAME_KEY, getClass().getName());
Files.createFile(dataFile);
try (OutputStream out = Files.newOutputStream(dataFile)) {
installProperties.store(out, null);
}
return installProperties;
}
private void deleteOrphanedTempDirs(@NonNull ProgressIndicator progress) {
Path root = mRepoManager.getLocalPath();
assert root != null;
FileSystem fileSystem = root.getFileSystem();
Path suffixPath =
root.getFileSystem().getPath(InstallerUtil.INSTALLER_DIR_FN, INSTALL_DATA_FN);
try (Stream<Path> paths = Files.walk(root)) {
Set<Path> tempDirs =
paths.filter(path -> path.endsWith(suffixPath))
.map(this::getPathPropertiesOrNull)
.filter(Objects::nonNull)
.map(props -> props.getProperty(PATH_KEY))
.map(fileSystem::getPath)
.collect(Collectors.toSet());
retainPackageOperationTempDirs(tempDirs, TEMP_DIR_PREFIX);
} catch (IOException e) {
progress.logWarning("Error while searching for in-use temporary directories.", e);
}
}
@VisibleForTesting
static Path getNewPackageOperationTempDir(
@NonNull RepoManager repoManager, @NonNull String base) {
for (int i = 1; i < MAX_PACKAGE_OPERATION_TEMP_DIRS; i++) {
Path folder = getPackageOperationTempDir(repoManager, base, i);
if (!CancellableFileIo.exists(folder)) {
try {
Files.createDirectories(folder);
return folder;
} catch (IOException ignore) {
// try again with the next index
}
}
}
return null;
}
@VisibleForTesting
static Path getPackageOperationTempDir(
@NonNull RepoManager repoManager, @NonNull String base, int index) {
Path rootTempDir = repoManager.getLocalPath().resolve(REPO_TEMP_DIR_FN);
return rootTempDir.resolve(String.format(Locale.US, "%1$s%2$02d", base, index));
}
private void retainPackageOperationTempDirs(Set<Path> retain, String base) {
for (int i = 1; i < MAX_PACKAGE_OPERATION_TEMP_DIRS; i++) {
Path dir = getPackageOperationTempDir(getRepoManager(), base, i);
if (CancellableFileIo.exists(dir) && !retain.contains(dir)) {
FileOpUtils.deleteFileOrFolder(dir);
}
}
}
@Nullable
private Properties getPathPropertiesOrNull(@NonNull Path path) {
try {
return readInstallProperties(path.getParent().getParent());
} catch (IOException e) {
return null;
}
}
@Nullable
private Path writeInstallerMetadata(@NonNull ProgressIndicator progress) throws IOException {
Path installPath = getLocation(progress);
Properties installProperties = readOrCreateInstallProperties(installPath, progress);
Path installTempPath =
installPath.getFileSystem().getPath((String) installProperties.get(PATH_KEY));
try {
Files.createDirectories(installPath);
} catch (IOException e) {
progress.logWarning("Failed to create output directory: " + installPath);
return null;
}
PathUtils.addRemovePathHook(installTempPath);
return installTempPath;
}
@NonNull
@Override
public RepoManager getRepoManager() {
return mRepoManager;
}
/**
* Registers a listener that will be called when the {@link InstallStatus} of
* this installer changes.
*/
@Override
public final void registerStateChangeListener(@NonNull StatusChangeListener listener) {
synchronized (mStateChangeLock) {
mListeners.add(listener);
}
}
/**
* Gets the current {@link InstallStatus} of this installer.
*/
@Override
@NonNull
public final InstallStatus getInstallStatus() {
return mInstallStatus;
}
/**
* Sets our status to {@code status} and notifies our listeners. If any listener throws an
* exception we will stop processing listeners and update our status to {@code
* InstallStatus.FAILED} (calling the listeners again with that status update).
*/
protected final boolean updateStatus(
@NonNull InstallStatus status, @NonNull ProgressIndicator progress) {
List<StatusChangeListener> listeners;
synchronized (mStateChangeLock) {
mInstallStatus = status;
listeners = new ArrayList<>(mListeners);
}
try {
for (StatusChangeListener listener : listeners) {
try {
listener.statusChanged(this, progress);
} catch (Exception e) {
if (status != InstallStatus.FAILED) {
throw e;
}
// else ignore and continue with the other listeners
}
}
} catch (Exception e) {
progress.logWarning("Failed to update status to " + status, e);
updateStatus(InstallStatus.FAILED, progress);
return false;
}
return true;
}
@Override
@Nullable
public PackageOperation getFallbackOperation() {
return mFallbackOperation;
}
@Override
public void setFallbackOperation(@Nullable PackageOperation mFallbackOperation) {
this.mFallbackOperation = mFallbackOperation;
}
private final Object mProgressLock = new Object();
@NonNull
private DelegatingProgressIndicator addProgress(
@NonNull ProgressIndicator progress, @Nullable DelegatingProgressIndicator existing) {
if (existing == null) {
existing = new DelegatingProgressIndicator(progress);
} else {
existing.addDelegate(progress);
}
return existing;
}
}