Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 2a0dd0e1 authored by Adam Lesinski's avatar Adam Lesinski Committed by Android (Google) Code Review
Browse files

Merge "AAPT2: Support compiling a res/ directory and output to zip"

parents 842ec9c4 a40e972f
Loading
Loading
Loading
Loading
+154 −65
Original line number Diff line number Diff line
@@ -22,6 +22,7 @@
#include "compile/IdAssigner.h"
#include "compile/Png.h"
#include "compile/XmlIdCollector.h"
#include "flatten/Archive.h"
#include "flatten/FileExportWriter.h"
#include "flatten/TableFlattener.h"
#include "flatten/XmlFlattener.h"
@@ -31,6 +32,7 @@
#include "xml/XmlDom.h"
#include "xml/XmlPullParser.h"

#include <dirent.h>
#include <fstream>
#include <string>

@@ -90,7 +92,7 @@ static Maybe<ResourcePathData> extractResourcePathData(const std::string& path,
    }

    return ResourcePathData{
            Source{ path },
            Source(path),
            util::utf8ToUtf16(dirStr),
            util::utf8ToUtf16(name),
            extension.toString(),
@@ -101,25 +103,79 @@ static Maybe<ResourcePathData> extractResourcePathData(const std::string& path,

struct CompileOptions {
    std::string outputPath;
    Maybe<std::string> resDir;
    Maybe<std::u16string> product;
    bool verbose = false;
};

static std::string buildIntermediateFilename(const std::string outDir,
                                             const ResourcePathData& data) {
static std::string buildIntermediateFilename(const ResourcePathData& data) {
    std::stringstream name;
    name << data.resourceDir;
    if (!data.configStr.empty()) {
        name << "-" << data.configStr;
    }
    name << "_" << data.name << "." << data.extension << ".flat";
    std::string outPath = outDir;
    file::appendPath(&outPath, name.str());
    return outPath;
    return name.str();
}

static bool isHidden(const StringPiece& filename) {
    return util::stringStartsWith<char>(filename, ".");
}

/**
 * Walks the res directory structure, looking for resource files.
 */
static bool loadInputFilesFromDir(IAaptContext* context, const CompileOptions& options,
                                  std::vector<ResourcePathData>* outPathData) {
    const std::string& rootDir = options.resDir.value();
    std::unique_ptr<DIR, decltype(closedir)*> d(opendir(rootDir.data()), closedir);
    if (!d) {
        context->getDiagnostics()->error(DiagMessage() << strerror(errno));
        return false;
    }

    while (struct dirent* entry = readdir(d.get())) {
        if (isHidden(entry->d_name)) {
            continue;
        }

        std::string prefixPath = rootDir;
        file::appendPath(&prefixPath, entry->d_name);

        if (file::getFileType(prefixPath) != file::FileType::kDirectory) {
            continue;
        }

        std::unique_ptr<DIR, decltype(closedir)*> subDir(opendir(prefixPath.data()), closedir);
        if (!subDir) {
            context->getDiagnostics()->error(DiagMessage() << strerror(errno));
            return false;
        }

        while (struct dirent* leafEntry = readdir(subDir.get())) {
            if (isHidden(leafEntry->d_name)) {
                continue;
            }

            std::string fullPath = prefixPath;
            file::appendPath(&fullPath, leafEntry->d_name);

            std::string errStr;
            Maybe<ResourcePathData> pathData = extractResourcePathData(fullPath, &errStr);
            if (!pathData) {
                context->getDiagnostics()->error(DiagMessage() << errStr);
                return false;
            }

            outPathData->push_back(std::move(pathData.value()));
        }
    }
    return true;
}

static bool compileTable(IAaptContext* context, const CompileOptions& options,
                         const ResourcePathData& pathData, const std::string& outputPath) {
                         const ResourcePathData& pathData, IArchiveWriter* writer,
                         const std::string& outputPath) {
    ResourceTable table;
    {
        std::ifstream fin(pathData.source.path, std::ifstream::binary);
@@ -150,6 +206,7 @@ static bool compileTable(IAaptContext* context, const CompileOptions& options,
    // Ensure we have the compilation package at least.
    table.createPackage(context->getCompilationPackage());

    // Assign an ID to any package that has resources.
    for (auto& pkg : table.packages) {
        if (!pkg->id) {
            // If no package ID was set while parsing (public identifiers), auto assign an ID.
@@ -172,23 +229,24 @@ static bool compileTable(IAaptContext* context, const CompileOptions& options,
        return false;
    }

    // Build the output filename.
    std::ofstream fout(outputPath, std::ofstream::binary);
    if (!fout) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
    if (!writer->startEntry(outputPath, 0)) {
        context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to open");
        return false;
    }

    // Write it to disk.
    if (!util::writeAll(fout, buffer)) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
        return false;
    }
    if (writer->writeEntry(buffer)) {
        if (writer->finishEntry()) {
            return true;
        }
    }

    context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to write");
    return false;
}

static bool compileXml(IAaptContext* context, const CompileOptions& options,
                       const ResourcePathData& pathData, const std::string& outputPath) {
                       const ResourcePathData& pathData, IArchiveWriter* writer,
                       const std::string& outputPath) {

    std::unique_ptr<xml::XmlResource> xmlRes;

@@ -214,7 +272,7 @@ static bool compileXml(IAaptContext* context, const CompileOptions& options,
        return false;
    }

    xmlRes->file.name = ResourceName{ {}, *parseResourceType(pathData.resourceDir), pathData.name };
    xmlRes->file.name = ResourceName({}, *parseResourceType(pathData.resourceDir), pathData.name);
    xmlRes->file.config = pathData.config;
    xmlRes->file.source = pathData.source;

@@ -230,25 +288,27 @@ static bool compileXml(IAaptContext* context, const CompileOptions& options,

    fileExportWriter.finish();

    std::ofstream fout(outputPath, std::ofstream::binary);
    if (!fout) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
    if (!writer->startEntry(outputPath, 0)) {
        context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to open");
        return false;
    }

    // Write it to disk.
    if (!util::writeAll(fout, buffer)) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
        return false;
    }
    if (writer->writeEntry(buffer)) {
        if (writer->finishEntry()) {
            return true;
        }
    }

    context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to write");
    return false;
}

static bool compilePng(IAaptContext* context, const CompileOptions& options,
                       const ResourcePathData& pathData, const std::string& outputPath) {
                       const ResourcePathData& pathData, IArchiveWriter* writer,
                       const std::string& outputPath) {
    BigBuffer buffer(4096);
    ResourceFile resFile;
    resFile.name = ResourceName{ {}, *parseResourceType(pathData.resourceDir), pathData.name };
    resFile.name = ResourceName({}, *parseResourceType(pathData.resourceDir), pathData.name);
    resFile.config = pathData.config;
    resFile.source = pathData.source;

@@ -269,24 +329,27 @@ static bool compilePng(IAaptContext* context, const CompileOptions& options,

    fileExportWriter.finish();

    std::ofstream fout(outputPath, std::ofstream::binary);
    if (!fout) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
    if (!writer->startEntry(outputPath, 0)) {
        context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to open");
        return false;
    }

    if (!util::writeAll(fout, buffer)) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
        return false;
    }
    if (writer->writeEntry(buffer)) {
        if (writer->finishEntry()) {
            return true;
        }
    }

    context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to write");
    return false;
}

static bool compileFile(IAaptContext* context, const CompileOptions& options,
                        const ResourcePathData& pathData, const std::string& outputPath) {
                        const ResourcePathData& pathData, IArchiveWriter* writer,
                        const std::string& outputPath) {
    BigBuffer buffer(256);
    ResourceFile resFile;
    resFile.name = ResourceName{ {}, *parseResourceType(pathData.resourceDir), pathData.name };
    resFile.name = ResourceName({}, *parseResourceType(pathData.resourceDir), pathData.name);
    resFile.config = pathData.config;
    resFile.source = pathData.source;

@@ -299,9 +362,8 @@ static bool compileFile(IAaptContext* context, const CompileOptions& options,
        return false;
    }

    std::ofstream fout(outputPath, std::ofstream::binary);
    if (!fout) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
    if (!writer->startEntry(outputPath, 0)) {
        context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to open");
        return false;
    }

@@ -309,17 +371,18 @@ static bool compileFile(IAaptContext* context, const CompileOptions& options,
    // the buffer the entire file.
    fileExportWriter.getChunkHeader()->size =
            util::hostToDevice32(buffer.size() + f.value().getDataLength());
    if (!util::writeAll(fout, buffer)) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
        return false;

    if (writer->writeEntry(buffer)) {
        if (writer->writeEntry(f.value().getDataPtr(), f.value().getDataLength())) {
            if (writer->finishEntry()) {
                return true;
            }
        }
    }

    if (!fout.write((const char*) f.value().getDataPtr(), f.value().getDataLength())) {
        context->getDiagnostics()->error(DiagMessage(Source{ outputPath }) << strerror(errno));
    context->getDiagnostics()->error(DiagMessage(outputPath) << "failed to write");
    return false;
}
    return true;
}

class CompileContext : public IAaptContext {
private:
@@ -359,6 +422,7 @@ int compile(const std::vector<StringPiece>& args) {
    Flags flags = Flags()
            .requiredFlag("-o", "Output path", &options.outputPath)
            .optionalFlag("--product", "Product type to compile", &product)
            .optionalFlag("--dir", "Directory to scan for resources", &options.resDir)
            .optionalSwitch("-v", "Enables verbose logging", &options.verbose);
    if (!flags.parse("aapt2 compile", args, &std::cerr)) {
        return 1;
@@ -369,8 +433,24 @@ int compile(const std::vector<StringPiece>& args) {
    }

    CompileContext context;
    std::unique_ptr<IArchiveWriter> archiveWriter;

    std::vector<ResourcePathData> inputData;
    if (options.resDir) {
        if (!flags.getArgs().empty()) {
            // Can't have both files and a resource directory.
            context.getDiagnostics()->error(DiagMessage() << "files given but --dir specified");
            flags.usage("aapt2 compile", &std::cerr);
            return 1;
        }

        if (!loadInputFilesFromDir(&context, options, &inputData)) {
            return 1;
        }

        archiveWriter = createZipFileArchiveWriter(context.getDiagnostics(), options.outputPath);

    } else {
        inputData.reserve(flags.getArgs().size());

        // Collect data from the path for each input file.
@@ -384,6 +464,13 @@ int compile(const std::vector<StringPiece>& args) {
            }
        }

        archiveWriter = createDirectoryArchiveWriter(context.getDiagnostics(), options.outputPath);
    }

    if (!archiveWriter) {
        return false;
    }

    bool error = false;
    for (ResourcePathData& pathData : inputData) {
        if (options.verbose) {
@@ -394,32 +481,34 @@ int compile(const std::vector<StringPiece>& args) {
            // Overwrite the extension.
            pathData.extension = "arsc";

            const std::string outputFilename = buildIntermediateFilename(
                    options.outputPath, pathData);
            if (!compileTable(&context, options, pathData, outputFilename)) {
            const std::string outputFilename = buildIntermediateFilename(pathData);
            if (!compileTable(&context, options, pathData, archiveWriter.get(), outputFilename)) {
                error = true;
            }

        } else {
            const std::string outputFilename = buildIntermediateFilename(options.outputPath,
                                                                         pathData);
            const std::string outputFilename = buildIntermediateFilename(pathData);
            if (const ResourceType* type = parseResourceType(pathData.resourceDir)) {
                if (*type != ResourceType::kRaw) {
                    if (pathData.extension == "xml") {
                        if (!compileXml(&context, options, pathData, outputFilename)) {
                        if (!compileXml(&context, options, pathData, archiveWriter.get(),
                                        outputFilename)) {
                            error = true;
                        }
                    } else if (pathData.extension == "png" || pathData.extension == "9.png") {
                        if (!compilePng(&context, options, pathData, outputFilename)) {
                        if (!compilePng(&context, options, pathData, archiveWriter.get(),
                                        outputFilename)) {
                            error = true;
                        }
                    } else {
                        if (!compileFile(&context, options, pathData, outputFilename)) {
                        if (!compileFile(&context, options, pathData, archiveWriter.get(),
                                         outputFilename)) {
                            error = true;
                        }
                    }
                } else {
                    if (!compileFile(&context, options, pathData, outputFilename)) {
                    if (!compileFile(&context, options, pathData, archiveWriter.get(),
                                     outputFilename)) {
                        error = true;
                    }
                }
+89 −86
Original line number Diff line number Diff line
@@ -18,7 +18,7 @@
#include "util/Files.h"
#include "util/StringPiece.h"

#include <fstream>
#include <cstdio>
#include <memory>
#include <string>
#include <vector>
@@ -30,70 +30,85 @@ namespace {

struct DirectoryWriter : public IArchiveWriter {
    std::string mOutDir;
    std::vector<std::unique_ptr<ArchiveEntry>> mEntries;
    std::unique_ptr<FILE, decltype(fclose)*> mFile = { nullptr, fclose };

    explicit DirectoryWriter(const StringPiece& outDir) : mOutDir(outDir.toString()) {
    bool open(IDiagnostics* diag, const StringPiece& outDir) {
        mOutDir = outDir.toString();
        file::FileType type = file::getFileType(mOutDir);
        if (type == file::FileType::kNonexistant) {
            diag->error(DiagMessage() << "directory " << mOutDir << " does not exist");
            return false;
        } else if (type != file::FileType::kDirectory) {
            diag->error(DiagMessage() << mOutDir << " is not a directory");
            return false;
        }

    ArchiveEntry* writeEntry(const StringPiece& path, uint32_t flags,
                             const BigBuffer& buffer) override {
        std::string fullPath = mOutDir;
        file::appendPath(&fullPath, path);
        file::mkdirs(file::getStem(fullPath));

        std::ofstream fout(fullPath, std::ofstream::binary);
        if (!fout) {
            return nullptr;
        return true;
    }

        if (!util::writeAll(fout, buffer)) {
            return nullptr;
        }

        mEntries.push_back(util::make_unique<ArchiveEntry>(fullPath, flags, buffer.size()));
        return mEntries.back().get();
    bool startEntry(const StringPiece& path, uint32_t flags) override {
        if (mFile) {
            return false;
        }

    ArchiveEntry* writeEntry(const StringPiece& path, uint32_t flags, android::FileMap* fileMap,
                             size_t offset, size_t len) override {
        std::string fullPath = mOutDir;
        file::appendPath(&fullPath, path);
        file::mkdirs(file::getStem(fullPath));

        std::ofstream fout(fullPath, std::ofstream::binary);
        if (!fout) {
            return nullptr;
        mFile = { fopen(fullPath.data(), "wb"), fclose };
        if (!mFile) {
            return false;
        }
        return true;
    }

        if (!fout.write((const char*) fileMap->getDataPtr() + offset, len)) {
            return nullptr;
    bool writeEntry(const BigBuffer& buffer) override {
        if (!mFile) {
            return false;
        }

        mEntries.push_back(util::make_unique<ArchiveEntry>(fullPath, flags, len));
        return mEntries.back().get();
        for (const BigBuffer::Block& b : buffer) {
            if (fwrite(b.buffer.get(), 1, b.size, mFile.get()) != b.size) {
                mFile.reset(nullptr);
                return false;
            }
        }
        return true;
    }

    virtual ~DirectoryWriter() {
    bool writeEntry(const void* data, size_t len) override {
        if (fwrite(data, 1, len, mFile.get()) != len) {
            mFile.reset(nullptr);
            return false;
        }
        return true;
    }

    bool finishEntry() override {
        if (!mFile) {
            return false;
        }
        mFile.reset(nullptr);
        return true;
    }
};

struct ZipFileWriter : public IArchiveWriter {
    FILE* mFile;
    std::unique_ptr<FILE, decltype(fclose)*> mFile = { nullptr, fclose };
    std::unique_ptr<ZipWriter> mWriter;
    std::vector<std::unique_ptr<ArchiveEntry>> mEntries;

    explicit ZipFileWriter(const StringPiece& path) {
        mFile = fopen(path.data(), "w+b");
        if (mFile) {
            mWriter = util::make_unique<ZipWriter>(mFile);
    bool open(IDiagnostics* diag, const StringPiece& path) {
        mFile = { fopen(path.data(), "w+b"), fclose };
        if (!mFile) {
            diag->error(DiagMessage() << "failed to open " << path << ": " << strerror(errno));
            return false;
        }
        mWriter = util::make_unique<ZipWriter>(mFile.get());
        return true;
    }

    ArchiveEntry* writeEntry(const StringPiece& path, uint32_t flags,
                             const BigBuffer& buffer) override {
    bool startEntry(const StringPiece& path, uint32_t flags) override {
        if (!mWriter) {
            return nullptr;
            return false;
        }

        size_t zipFlags = 0;
@@ -107,75 +122,63 @@ struct ZipFileWriter : public IArchiveWriter {

        int32_t result = mWriter->StartEntry(path.data(), zipFlags);
        if (result != 0) {
            return nullptr;
        }

        for (const BigBuffer::Block& b : buffer) {
            result = mWriter->WriteBytes(reinterpret_cast<const uint8_t*>(b.buffer.get()), b.size);
            if (result != 0) {
                return nullptr;
            return false;
        }
        return true;
    }

        result = mWriter->FinishEntry();
    bool writeEntry(const void* data, size_t len) override {
        int32_t result = mWriter->WriteBytes(data, len);
        if (result != 0) {
            return nullptr;
        }

        mEntries.push_back(util::make_unique<ArchiveEntry>(path.toString(), flags, buffer.size()));
        return mEntries.back().get();
            return false;
        }

    ArchiveEntry* writeEntry(const StringPiece& path, uint32_t flags, android::FileMap* fileMap,
                             size_t offset, size_t len) override {
        if (!mWriter) {
            return nullptr;
        return true;
    }

        size_t zipFlags = 0;
        if (flags & ArchiveEntry::kCompress) {
            zipFlags |= ZipWriter::kCompress;
        }

        if (flags & ArchiveEntry::kAlign) {
            zipFlags |= ZipWriter::kAlign32;
        }

        int32_t result = mWriter->StartEntry(path.data(), zipFlags);
    bool writeEntry(const BigBuffer& buffer) override {
        for (const BigBuffer::Block& b : buffer) {
            int32_t result = mWriter->WriteBytes(b.buffer.get(), b.size);
            if (result != 0) {
            return nullptr;
                return false;
            }

        result = mWriter->WriteBytes((const char*) fileMap->getDataPtr() + offset, len);
        if (result != 0) {
            return nullptr;
        }
        return true;
    }

        result = mWriter->FinishEntry();
    bool finishEntry() override {
        int32_t result = mWriter->FinishEntry();
        if (result != 0) {
            return nullptr;
            return false;
        }

        mEntries.push_back(util::make_unique<ArchiveEntry>(path.toString(), flags, len));
        return mEntries.back().get();
        return true;
    }

    virtual ~ZipFileWriter() {
        if (mWriter) {
            mWriter->Finish();
            fclose(mFile);
        }
    }
};

} // namespace

std::unique_ptr<IArchiveWriter> createDirectoryArchiveWriter(const StringPiece& path) {
    return util::make_unique<DirectoryWriter>(path);
std::unique_ptr<IArchiveWriter> createDirectoryArchiveWriter(IDiagnostics* diag,
                                                             const StringPiece& path) {

    std::unique_ptr<DirectoryWriter> writer = util::make_unique<DirectoryWriter>();
    if (!writer->open(diag, path)) {
        return {};
    }
    return std::move(writer);
}

std::unique_ptr<IArchiveWriter> createZipFileArchiveWriter(const StringPiece& path) {
    return util::make_unique<ZipFileWriter>(path);
std::unique_ptr<IArchiveWriter> createZipFileArchiveWriter(IDiagnostics* diag,
                                                           const StringPiece& path) {
    std::unique_ptr<ZipFileWriter> writer = util::make_unique<ZipFileWriter>();
    if (!writer->open(diag, path)) {
        return {};
    }
    return std::move(writer);
}

} // namespace aapt
+9 −6
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@
#ifndef AAPT_FLATTEN_ARCHIVE_H
#define AAPT_FLATTEN_ARCHIVE_H

#include "Diagnostics.h"
#include "util/BigBuffer.h"
#include "util/Files.h"
#include "util/StringPiece.h"
@@ -42,15 +43,17 @@ struct ArchiveEntry {
struct IArchiveWriter {
    virtual ~IArchiveWriter() = default;

    virtual ArchiveEntry* writeEntry(const StringPiece& path, uint32_t flags,
                                     const BigBuffer& buffer) = 0;
    virtual ArchiveEntry* writeEntry(const StringPiece& path, uint32_t flags,
                                     android::FileMap* fileMap, size_t offset, size_t len) = 0;
    virtual bool startEntry(const StringPiece& path, uint32_t flags) = 0;
    virtual bool writeEntry(const BigBuffer& buffer) = 0;
    virtual bool writeEntry(const void* data, size_t len) = 0;
    virtual bool finishEntry() = 0;
};

std::unique_ptr<IArchiveWriter> createDirectoryArchiveWriter(const StringPiece& path);
std::unique_ptr<IArchiveWriter> createDirectoryArchiveWriter(IDiagnostics* diag,
                                                             const StringPiece& path);

std::unique_ptr<IArchiveWriter> createZipFileArchiveWriter(const StringPiece& path);
std::unique_ptr<IArchiveWriter> createZipFileArchiveWriter(IDiagnostics* diag,
                                                           const StringPiece& path);

} // namespace aapt

tools/aapt2/io/Data.h

0 → 100644
+85 −0
Original line number Diff line number Diff line
/*
 * 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.
 */

#ifndef AAPT_IO_DATA_H
#define AAPT_IO_DATA_H

#include <utils/FileMap.h>

#include <memory>

namespace aapt {
namespace io {

/**
 * Interface for a block of contiguous memory. An instance of this interface owns the data.
 */
class IData {
public:
    virtual ~IData() = default;

    virtual const void* data() const = 0;
    virtual size_t size() const = 0;
};

/**
 * Implementation of IData that exposes a memory mapped file. The mmapped file is owned by this
 * object.
 */
class MmappedData : public IData {
public:
    explicit MmappedData(android::FileMap&& map) : mMap(std::forward<android::FileMap>(map)) {
    }

    const void* data() const override {
        return mMap.getDataPtr();
    }

    size_t size() const override {
        return mMap.getDataLength();
    }

private:
    android::FileMap mMap;
};

/**
 * Implementation of IData that exposes a block of memory that was malloc'ed (new'ed). The
 * memory is owned by this object.
 */
class MallocData : public IData {
public:
    MallocData(std::unique_ptr<const uint8_t[]> data, size_t size) :
            mData(std::move(data)), mSize(size) {
    }

    const void* data() const override {
        return mData.get();
    }

    size_t size() const override {
        return mSize;
    }

private:
    std::unique_ptr<const uint8_t[]> mData;
    size_t mSize;
};

} // namespace io
} // namespace aapt

#endif /* AAPT_IO_DATA_H */

tools/aapt2/io/File.h

0 → 100644
+72 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading