blob: 8a01fa84d4bdad40cd5c8f2cc5509b000d91951f [file] [log] [blame]
// Copyright 2016 Google Inc. All rights reserved.
//
// 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.google.archivepatcher.explainer;
import com.google.archivepatcher.generator.ByteArrayHolder;
import com.google.archivepatcher.generator.DeltaGenerator;
import com.google.archivepatcher.generator.MinimalZipArchive;
import com.google.archivepatcher.generator.MinimalZipEntry;
import com.google.archivepatcher.generator.PreDiffExecutor;
import com.google.archivepatcher.generator.PreDiffPlan;
import com.google.archivepatcher.generator.QualifiedRecommendation;
import com.google.archivepatcher.generator.RecommendationModifier;
import com.google.archivepatcher.generator.RecommendationReason;
import com.google.archivepatcher.generator.TempFileHolder;
import com.google.archivepatcher.shared.Compressor;
import com.google.archivepatcher.shared.CountingOutputStream;
import com.google.archivepatcher.shared.DeflateUncompressor;
import com.google.archivepatcher.shared.RandomAccessFileInputStream;
import com.google.archivepatcher.shared.Uncompressor;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Explains where the data in a patch would come from. */
// TODO: Add explicit logic for renames
public class PatchExplainer {
/**
* A stream that discards everything written to it.
*/
private static class NullOutputStream extends OutputStream {
@Override
public void write(int b) throws IOException {
// Nothing.
}
@Override
public void write(byte[] b) throws IOException {
// Nothing.
}
@Override
public void write(byte[] b, int off, int len) throws IOException {
// Nothing.
}
}
/**
* The compressor to use for compressing patch content.
*/
private final Compressor compressor;
/**
* The delta generator to use for generating uncompressed patch content.
*/
private final DeltaGenerator deltaGenerator;
/**
* Construct a new patch explainer that will use the specified {@link Compressor} to establish
* compressed patch size estimates and the specified {@link DeltaGenerator} to generate the deltas
* for the patch.
* @param compressor the compressor to use
* @param deltaGenerator the delta generator to use
*/
public PatchExplainer(Compressor compressor, DeltaGenerator deltaGenerator) {
this.compressor = compressor;
this.deltaGenerator = deltaGenerator;
}
/**
* Explains the patch that would be generated for the specified input files.
*
* @param oldFile the old file
* @param newFile the new file
* @param recommendationModifiers optionally, {@link RecommendationModifier}s to use during patch
* planning. If null, a normal patch is generated.
* @return a list of the explanations for each entry that would be
* @throws IOException if unable to read data
* @throws InterruptedException if any thread interrupts this thread
*/
public List<EntryExplanation> explainPatch(
File oldFile, File newFile, RecommendationModifier... recommendationModifiers)
throws IOException, InterruptedException {
List<EntryExplanation> result = new ArrayList<>();
// Isolate entries that are only found in the new archive.
Map<ByteArrayHolder, MinimalZipEntry> allOldEntries = mapEntries(oldFile);
Map<ByteArrayHolder, MinimalZipEntry> allNewEntries = mapEntries(newFile);
Map<ByteArrayHolder, MinimalZipEntry> completelyNewEntries = new HashMap<>(allNewEntries);
completelyNewEntries.keySet().removeAll(allOldEntries.keySet());
// Now calculate the costs for the new files and track them in the explanations returned.
for (Map.Entry<ByteArrayHolder, MinimalZipEntry> entry : completelyNewEntries.entrySet()) {
long compressedSize = getCompressedSize(newFile, entry.getValue(), compressor);
result.add(
new EntryExplanation(
new ByteArrayHolder(entry.getValue().getFileNameBytes()),
true,
null,
compressedSize));
}
Uncompressor uncompressor = new DeflateUncompressor();
PreDiffExecutor.Builder builder =
new PreDiffExecutor.Builder().readingOriginalFiles(oldFile, newFile);
for (RecommendationModifier modifier : recommendationModifiers) {
builder.withRecommendationModifier(modifier);
}
PreDiffExecutor executor = builder.build();
PreDiffPlan plan = executor.prepareForDiffing();
try (TempFileHolder oldTemp = new TempFileHolder();
TempFileHolder newTemp = new TempFileHolder();
TempFileHolder deltaTemp = new TempFileHolder()) {
for (QualifiedRecommendation qualifiedRecommendation : plan.getQualifiedRecommendations()) {
// Short-circuit for identical resources.
if (qualifiedRecommendation.getReason()
== RecommendationReason.COMPRESSED_BYTES_IDENTICAL) {
// Patch size should be effectively zero.
result.add(
new EntryExplanation(
new ByteArrayHolder(qualifiedRecommendation.getNewEntry().getFileNameBytes()),
false,
qualifiedRecommendation.getReason(),
0L));
continue;
}
if (qualifiedRecommendation.getOldEntry().getCrc32OfUncompressedData()
== qualifiedRecommendation.getNewEntry().getCrc32OfUncompressedData()
&& qualifiedRecommendation.getOldEntry().getUncompressedSize()
== qualifiedRecommendation.getNewEntry().getUncompressedSize()) {
// If the path, size and CRC32 are the same assume it's a match. Patch size should be
// effectively zero.
result.add(
new EntryExplanation(
new ByteArrayHolder(qualifiedRecommendation.getNewEntry().getFileNameBytes()),
false,
qualifiedRecommendation.getReason(),
0L));
continue;
}
// Everything past here is a resource that has changed in some way.
// NB: This magically takes care of RecommendationReason.RESOURCE_CONSTRAINED. The logic
// below will keep the RESOURCE_CONSTRAINED entries compressed, running the delta on their
// compressed contents, and the resulting explanation will preserve the RESOURCE_CONSTRAINED
// reason. This will correctly attribute the size of these blobs to the RESOURCE_CONSTRAINED
// category.
// Get the inputs ready for running a delta: uncompress/copy the *old* content as necessary.
long oldOffset = qualifiedRecommendation.getOldEntry().getFileOffsetOfCompressedData();
long oldLength = qualifiedRecommendation.getOldEntry().getCompressedSize();
if (qualifiedRecommendation.getRecommendation().uncompressOldEntry) {
uncompress(oldFile, oldOffset, oldLength, uncompressor, oldTemp.file);
} else {
extractCopy(oldFile, oldOffset, oldLength, oldTemp.file);
}
// Get the inputs ready for running a delta: uncompress/copy the *new* content as necessary.
long newOffset = qualifiedRecommendation.getNewEntry().getFileOffsetOfCompressedData();
long newLength = qualifiedRecommendation.getNewEntry().getCompressedSize();
if (qualifiedRecommendation.getRecommendation().uncompressNewEntry) {
uncompress(newFile, newOffset, newLength, uncompressor, newTemp.file);
} else {
extractCopy(newFile, newOffset, newLength, newTemp.file);
}
// File is actually changed (or transitioned between compressed and uncompressed forms).
// Generate and compress a delta.
try (FileOutputStream deltaOut = new FileOutputStream(deltaTemp.file);
BufferedOutputStream bufferedDeltaOut = new BufferedOutputStream(deltaOut)) {
deltaGenerator.generateDelta(oldTemp.file, newTemp.file, bufferedDeltaOut);
bufferedDeltaOut.flush();
long compressedDeltaSize =
getCompressedSize(deltaTemp.file, 0, deltaTemp.file.length(), compressor);
result.add(
new EntryExplanation(
new ByteArrayHolder(qualifiedRecommendation.getOldEntry().getFileNameBytes()),
false,
qualifiedRecommendation.getReason(),
compressedDeltaSize));
}
}
}
return result;
}
/**
* Determines the size of the entry if it were compressed with the specified compressor.
* @param file the file to read from
* @param entry the entry to estimate the size of
* @param compressor the compressor to use for compressing
* @return the size of the entry if compressed with the specified compressor
* @throws IOException if anything goes wrong
*/
private long getCompressedSize(File file, MinimalZipEntry entry, Compressor compressor)
throws IOException {
return getCompressedSize(
file, entry.getFileOffsetOfCompressedData(), entry.getCompressedSize(), compressor);
}
/**
* Uncompress the specified content to a new file.
* @param source the file to read from
* @param offset the offset at which to start reading
* @param length the number of bytes to uncompress
* @param uncompressor the uncompressor to use
* @param dest the file to write the uncompressed bytes to
* @throws IOException if anything goes wrong
*/
private void uncompress(
File source, long offset, long length, Uncompressor uncompressor, File dest)
throws IOException {
try (RandomAccessFileInputStream rafis =
new RandomAccessFileInputStream(source, offset, length);
FileOutputStream out = new FileOutputStream(dest);
BufferedOutputStream bufferedOut = new BufferedOutputStream(out)) {
uncompressor.uncompress(rafis, bufferedOut);
}
}
/**
* Extract a copy of the specified content to a new file.
* @param source the file to read from
* @param offset the offset at which to start reading
* @param length the number of bytes to uncompress
* @param dest the file to write the uncompressed bytes to
* @throws IOException if anything goes wrong
*/
private void extractCopy(File source, long offset, long length, File dest) throws IOException {
try (RandomAccessFileInputStream rafis =
new RandomAccessFileInputStream(source, offset, length);
FileOutputStream out = new FileOutputStream(dest);
BufferedOutputStream bufferedOut = new BufferedOutputStream(out)) {
byte[] buffer = new byte[32768];
int numRead = 0;
while ((numRead = rafis.read(buffer)) >= 0) {
bufferedOut.write(buffer, 0, numRead);
}
bufferedOut.flush();
}
}
/**
* Compresses an arbitrary range of bytes in the given file and returns the compressed size.
* @param file the file to read from
* @param offset the offset in the file to start reading from
* @param length the number of bytes to read from the input file
* @param compressor the compressor to use for compressing
* @return the size of the entry if compressed with the specified compressor
* @throws IOException if anything goes wrong
*/
private long getCompressedSize(File file, long offset, long length, Compressor compressor)
throws IOException {
try (OutputStream sink = new NullOutputStream();
CountingOutputStream counter = new CountingOutputStream(sink);
RandomAccessFileInputStream rafis = new RandomAccessFileInputStream(file, offset, length)) {
compressor.compress(rafis, counter);
counter.flush();
return counter.getNumBytesWritten();
}
}
/**
* Convert a file into a map whose keys are {@link ByteArrayHolder} objects containing the entry
* paths and whose values are the corresponding {@link MinimalZipEntry} objects.
* @param file the file to scan, which must be a valid zip archive
* @return the mapping, as described
* @throws IOException if anything goes wrong
*/
private static Map<ByteArrayHolder, MinimalZipEntry> mapEntries(File file) throws IOException {
List<MinimalZipEntry> allEntries = MinimalZipArchive.listEntries(file);
Map<ByteArrayHolder, MinimalZipEntry> result = new HashMap<>(allEntries.size());
for (MinimalZipEntry entry : allEntries) {
result.put(new ByteArrayHolder(entry.getFileNameBytes()), entry);
}
return result;
}
}