// Copyright 2017 The Bazel Authors. 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.devtools.build.android;

import static com.google.common.base.Predicates.not;
import static com.google.common.base.Verify.verify;
import static java.util.stream.Collectors.toList;

import android.aapt.pb.internal.ResourcesInternal.CompiledFile;
import com.android.aapt.ConfigurationOuterClass.Configuration;
import com.android.aapt.ConfigurationOuterClass.Configuration.KeysHidden;
import com.android.aapt.ConfigurationOuterClass.Configuration.NavHidden;
import com.android.aapt.ConfigurationOuterClass.Configuration.Orientation;
import com.android.aapt.ConfigurationOuterClass.Configuration.ScreenLayoutLong;
import com.android.aapt.ConfigurationOuterClass.Configuration.ScreenLayoutSize;
import com.android.aapt.ConfigurationOuterClass.Configuration.Touchscreen;
import com.android.aapt.ConfigurationOuterClass.Configuration.UiModeNight;
import com.android.aapt.ConfigurationOuterClass.Configuration.UiModeType;
import com.android.aapt.Resources;
import com.android.aapt.Resources.ConfigValue;
import com.android.aapt.Resources.Package;
import com.android.aapt.Resources.ResourceTable;
import com.android.aapt.Resources.Value;
import com.android.aapt.Resources.XmlNode;
import com.android.ide.common.resources.configuration.CountryCodeQualifier;
import com.android.ide.common.resources.configuration.DensityQualifier;
import com.android.ide.common.resources.configuration.FolderConfiguration;
import com.android.ide.common.resources.configuration.KeyboardStateQualifier;
import com.android.ide.common.resources.configuration.LayoutDirectionQualifier;
import com.android.ide.common.resources.configuration.LocaleQualifier;
import com.android.ide.common.resources.configuration.NavigationMethodQualifier;
import com.android.ide.common.resources.configuration.NavigationStateQualifier;
import com.android.ide.common.resources.configuration.NetworkCodeQualifier;
import com.android.ide.common.resources.configuration.NightModeQualifier;
import com.android.ide.common.resources.configuration.ResourceQualifier;
import com.android.ide.common.resources.configuration.ScreenDimensionQualifier;
import com.android.ide.common.resources.configuration.ScreenHeightQualifier;
import com.android.ide.common.resources.configuration.ScreenOrientationQualifier;
import com.android.ide.common.resources.configuration.ScreenRatioQualifier;
import com.android.ide.common.resources.configuration.ScreenRoundQualifier;
import com.android.ide.common.resources.configuration.ScreenSizeQualifier;
import com.android.ide.common.resources.configuration.ScreenWidthQualifier;
import com.android.ide.common.resources.configuration.SmallestScreenWidthQualifier;
import com.android.ide.common.resources.configuration.TextInputMethodQualifier;
import com.android.ide.common.resources.configuration.TouchScreenQualifier;
import com.android.ide.common.resources.configuration.UiModeQualifier;
import com.android.ide.common.resources.configuration.VersionQualifier;
import com.android.resources.Density;
import com.android.resources.Keyboard;
import com.android.resources.KeyboardState;
import com.android.resources.LayoutDirection;
import com.android.resources.Navigation;
import com.android.resources.NavigationState;
import com.android.resources.NightMode;
import com.android.resources.ResourceType;
import com.android.resources.ScreenOrientation;
import com.android.resources.ScreenRatio;
import com.android.resources.ScreenRound;
import com.android.resources.ScreenSize;
import com.android.resources.TouchScreen;
import com.android.resources.UiMode;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Stopwatch;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.common.io.ByteStreams;
import com.google.common.io.LittleEndianDataInputStream;
import com.google.devtools.build.android.aapt2.CompiledResources;
import com.google.devtools.build.android.aapt2.ResourceCompiler;
import com.google.devtools.build.android.proto.SerializeFormat;
import com.google.devtools.build.android.proto.SerializeFormat.Header;
import com.google.devtools.build.android.resources.Visibility;
import com.google.devtools.build.android.xml.ResourcesAttribute.AttributeType;
import com.google.protobuf.ExtensionRegistry;
import com.google.protobuf.InvalidProtocolBufferException;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import javax.annotation.Nullable;
import javax.annotation.concurrent.NotThreadSafe;

/** Deserializes {@link DataKey}, {@link DataValue} entries from compiled resource files. */
public class AndroidCompiledDataDeserializer implements AndroidDataDeserializer {
  private static final Logger logger =
      Logger.getLogger(AndroidCompiledDataDeserializer.class.getName());

  // Magic numbers from
  // https://android.googlesource.com/platform/frameworks/base.git/+/refs/tags/platform-tools-29.0.2/tools/aapt2/formats.md
  //
  // TODO(b/143116130): aapt2 should just use protobuf directly instead of this custom
  // layer on top.
  private static final int AAPT_CONTAINER_MAGIC = 0x54504141; // "AAPT" as little-endian
  private static final int AAPT_CONTAINER_VERSION = 1;
  private static final int AAPT_CONTAINER_ENTRY_RES_TABLE = 0;
  private static final int AAPT_CONTAINER_ENTRY_RES_FILE = 1;

  // aapt2 insists on aligning things to 4-byte boundaries in *.flat files, though there's no actual
  // performance or maintainability benefit.
  private static final int AAPT_FLAT_FILE_ALIGNMENT = 4;

  private static final ImmutableMap<Configuration.LayoutDirection, LayoutDirection>
      LAYOUT_DIRECTION_MAP =
          ImmutableMap.of(
              Configuration.LayoutDirection.LAYOUT_DIRECTION_LTR,
              LayoutDirection.LTR,
              Configuration.LayoutDirection.LAYOUT_DIRECTION_RTL,
              LayoutDirection.RTL);

  private static final ImmutableMap<Configuration.ScreenLayoutSize, ScreenSize> LAYOUT_SIZE_MAP =
      ImmutableMap.of(
          ScreenLayoutSize.SCREEN_LAYOUT_SIZE_SMALL,
          ScreenSize.SMALL,
          ScreenLayoutSize.SCREEN_LAYOUT_SIZE_NORMAL,
          ScreenSize.NORMAL,
          ScreenLayoutSize.SCREEN_LAYOUT_SIZE_LARGE,
          ScreenSize.LARGE,
          ScreenLayoutSize.SCREEN_LAYOUT_SIZE_XLARGE,
          ScreenSize.XLARGE);

  private static final ImmutableMap<Configuration.ScreenLayoutLong, ScreenRatio> SCREEN_LONG_MAP =
      ImmutableMap.of(
          ScreenLayoutLong.SCREEN_LAYOUT_LONG_LONG,
          ScreenRatio.LONG,
          ScreenLayoutLong.SCREEN_LAYOUT_LONG_NOTLONG,
          ScreenRatio.NOTLONG);

  private static final ImmutableMap<Configuration.ScreenRound, ScreenRound> SCREEN_ROUND_MAP =
      ImmutableMap.of(
          Configuration.ScreenRound.SCREEN_ROUND_ROUND, ScreenRound.ROUND,
          Configuration.ScreenRound.SCREEN_ROUND_NOTROUND, ScreenRound.NOTROUND);

  private static final ImmutableMap<Configuration.Orientation, ScreenOrientation>
      SCREEN_ORIENTATION_MAP =
          ImmutableMap.of(
              Orientation.ORIENTATION_LAND, ScreenOrientation.LANDSCAPE,
              Orientation.ORIENTATION_PORT, ScreenOrientation.PORTRAIT,
              Orientation.ORIENTATION_SQUARE, ScreenOrientation.SQUARE);

  private static final ImmutableMap<UiModeType, UiMode> SCREEN_UI_MODE =
      ImmutableMap.<UiModeType, UiMode>builder()
          .put(UiModeType.UI_MODE_TYPE_APPLIANCE, UiMode.APPLIANCE)
          .put(UiModeType.UI_MODE_TYPE_CAR, UiMode.CAR)
          .put(UiModeType.UI_MODE_TYPE_DESK, UiMode.DESK)
          .put(UiModeType.UI_MODE_TYPE_NORMAL, UiMode.NORMAL)
          .put(UiModeType.UI_MODE_TYPE_TELEVISION, UiMode.TELEVISION)
          .put(UiModeType.UI_MODE_TYPE_VRHEADSET, UiMode.NORMAL)
          .put(UiModeType.UI_MODE_TYPE_WATCH, UiMode.WATCH)
          .build();

  private static final ImmutableMap<Configuration.UiModeNight, NightMode> NIGHT_MODE_MAP =
      ImmutableMap.of(
          UiModeNight.UI_MODE_NIGHT_NIGHT, NightMode.NIGHT,
          UiModeNight.UI_MODE_NIGHT_NOTNIGHT, NightMode.NOTNIGHT);

  private static final ImmutableMap<Configuration.KeysHidden, KeyboardState> KEYBOARD_STATE_MAP =
      ImmutableMap.of(
          KeysHidden.KEYS_HIDDEN_KEYSEXPOSED,
          KeyboardState.EXPOSED,
          KeysHidden.KEYS_HIDDEN_KEYSSOFT,
          KeyboardState.SOFT,
          KeysHidden.KEYS_HIDDEN_KEYSHIDDEN,
          KeyboardState.HIDDEN);

  private static final ImmutableMap<Configuration.Touchscreen, TouchScreen> TOUCH_TYPE_MAP =
      ImmutableMap.of(
          Touchscreen.TOUCHSCREEN_FINGER,
          TouchScreen.FINGER,
          Touchscreen.TOUCHSCREEN_NOTOUCH,
          TouchScreen.NOTOUCH,
          Touchscreen.TOUCHSCREEN_STYLUS,
          TouchScreen.STYLUS);

  private static final ImmutableMap<Configuration.Keyboard, Keyboard> KEYBOARD_MAP =
      ImmutableMap.of(
          Configuration.Keyboard.KEYBOARD_NOKEYS,
          Keyboard.NOKEY,
          Configuration.Keyboard.KEYBOARD_QWERTY,
          Keyboard.QWERTY,
          Configuration.Keyboard.KEYBOARD_TWELVEKEY,
          Keyboard.TWELVEKEY);

  private static final ImmutableMap<Configuration.NavHidden, NavigationState> NAV_STATE_MAP =
      ImmutableMap.of(
          NavHidden.NAV_HIDDEN_NAVHIDDEN,
          NavigationState.HIDDEN,
          NavHidden.NAV_HIDDEN_NAVEXPOSED,
          NavigationState.EXPOSED);

  private static final ImmutableMap<Configuration.Navigation, Navigation> NAVIGATION_MAP =
      ImmutableMap.of(
          Configuration.Navigation.NAVIGATION_DPAD,
          Navigation.DPAD,
          Configuration.Navigation.NAVIGATION_NONAV,
          Navigation.NONAV,
          Configuration.Navigation.NAVIGATION_TRACKBALL,
          Navigation.TRACKBALL,
          Configuration.Navigation.NAVIGATION_WHEEL,
          Navigation.WHEEL);

  private static final ImmutableMap<Integer, Density> DENSITY_MAP =
      ImmutableMap.<Integer, Density>builder()
          .put(0xfffe, Density.ANYDPI)
          .put(0xffff, Density.NODPI)
          .put(120, Density.LOW)
          .put(160, Density.MEDIUM)
          .put(213, Density.TV)
          .put(240, Density.HIGH)
          .put(320, Density.XHIGH)
          .put(480, Density.XXHIGH)
          .put(640, Density.XXXHIGH)
          .build();

  private final boolean includeFileContentsForValidation;

  public static AndroidCompiledDataDeserializer create(boolean includeFileContentsForValidation) {
    return new AndroidCompiledDataDeserializer(includeFileContentsForValidation);
  }

  private AndroidCompiledDataDeserializer(boolean includeFileContentsForValidation) {
    this.includeFileContentsForValidation = includeFileContentsForValidation;
  }

  private static void consumeResourceTable(
      DependencyInfo dependencyInfo,
      KeyValueConsumers consumers,
      ResourceTable resourceTable,
      VisibilityRegistry registry)
      throws UnsupportedEncodingException, InvalidProtocolBufferException {
    List<String> sourcePool =
        decodeSourcePool(resourceTable.getSourcePool().getData().toByteArray());
    ReferenceResolver resolver = ReferenceResolver.asRoot();

    for (Package resourceTablePackage : resourceTable.getPackageList()) {
      ReferenceResolver packageResolver =
          resolver.resolveFor(resourceTablePackage.getPackageName());
      String packageName = resourceTablePackage.getPackageName();

      for (Resources.Type resourceFormatType : resourceTablePackage.getTypeList()) {
        ResourceType resourceType = ResourceType.getEnum(resourceFormatType.getName());

        for (Resources.Entry resource : resourceFormatType.getEntryList()) {
          if (!"android".equals(packageName)) {
            // This means this resource is not in the android sdk, add it to the set.
            for (ConfigValue configValue : resource.getConfigValueList()) {
              FullyQualifiedName fqn =
                  createAndRecordFqn(
                      packageResolver,
                      packageName,
                      resourceType,
                      resource,
                      convertToQualifiers(configValue.getConfig()));
              Visibility visibility =
                  registry.getVisibility(
                      ResourceName.create(packageName, resourceType, resource.getName()));

              int sourceIndex = configValue.getValue().getSource().getPathIdx();
              String source = sourcePool.get(sourceIndex);
              DataSource dataSource = DataSource.of(dependencyInfo, Paths.get(source));

              Value resourceValue = configValue.getValue();
              DataResource dataResource =
                  resourceValue.getItem().hasFile()
                      ? DataValueFile.of(
                          visibility, dataSource, /*fingerprint=*/ null, /*rootXmlNode=*/ null)
                      : DataResourceXml.from(
                          resourceValue, visibility, dataSource, resourceType, packageResolver);

              if (!fqn.isOverwritable()) {
                consumers.combiningConsumer.accept(fqn, dataResource);
              } else {
                consumers.overwritingConsumer.accept(fqn, dataResource);
              }
            }
          } else {
            // In the sdk, just add the fqn for styleables
            createAndRecordFqn(
                packageResolver, packageName, resourceType, resource, ImmutableList.of());
          }
        }
      }
    }
  }

  /** Maintains state for all references in each package of a resource table. */
  // TODO(b/112848607): Remove this!  This machinery is all really for pretty-printing styleables,
  // and only ever used for emitting XML with tools:keep attributes.
  // https://github.com/bazelbuild/bazel/blob/2419d4b2780fc68a0e501c1fab558b045eb054d3/src/tools/android/java/com/google/devtools/build/android/aapt2/ResourceLinker.java#L523
  @NotThreadSafe
  public static class ReferenceResolver {

    enum InlineStatus {
      INLINEABLE,
      INLINED,
    }

    private final Optional<String> packageName;
    private final Map<FullyQualifiedName, InlineStatus> qualifiedReferenceInlineStatus;

    private ReferenceResolver(
        Optional<String> packageName,
        Map<FullyQualifiedName, InlineStatus> qualifiedReferenceInlineStatus) {
      this.packageName = packageName;
      this.qualifiedReferenceInlineStatus = qualifiedReferenceInlineStatus;
    }

    static ReferenceResolver asRoot() {
      return new ReferenceResolver(Optional.empty(), new LinkedHashMap<>());
    }

    public ReferenceResolver resolveFor(String packageName) {
      return new ReferenceResolver(
          Optional.of(packageName).filter(not(String::isEmpty)), qualifiedReferenceInlineStatus);
    }

    public FullyQualifiedName parse(String reference) {
      return FullyQualifiedName.fromReference(reference, packageName);
    }

    public FullyQualifiedName register(FullyQualifiedName fullyQualifiedName) {
      // The default is that the name can be inlined.
      qualifiedReferenceInlineStatus.put(fullyQualifiedName, InlineStatus.INLINEABLE);
      return fullyQualifiedName;
    }

    /** Indicates if a reference can be inlined in a styleable. */
    public boolean shouldInline(FullyQualifiedName reference) {
      // Only inline if it's in the current package.
      if (!reference.isInPackage(packageName.orElse(FullyQualifiedName.DEFAULT_PACKAGE))) {
        return false;
      }

      return InlineStatus.INLINEABLE.equals(qualifiedReferenceInlineStatus.get(reference));
    }

    /** Update the reference's inline state. */
    public FullyQualifiedName markInlined(FullyQualifiedName reference) {
      qualifiedReferenceInlineStatus.put(reference, InlineStatus.INLINED);
      return reference;
    }
  }

  private static FullyQualifiedName createAndRecordFqn(
      ReferenceResolver packageResolver,
      String packageName,
      ResourceType resourceType,
      Resources.Entry resource,
      List<String> qualifiers) {
    final FullyQualifiedName fqn =
        FullyQualifiedName.of(
            packageName.isEmpty() ? FullyQualifiedName.DEFAULT_PACKAGE : packageName,
            qualifiers,
            resourceType,
            resource.getName());
    packageResolver.register(fqn);
    return fqn;
  }

  // TODO(b/146498565): remove this and use 'Configuration' directly, which is typesafe and free.
  private static List<String> convertToQualifiers(Configuration protoConfig) {
    FolderConfiguration configuration = new FolderConfiguration();
    if (protoConfig.getMcc() > 0) {
      configuration.setCountryCodeQualifier(new CountryCodeQualifier(protoConfig.getMcc()));
    }
    // special code for 0, as MNC can be zero
    // https://android.googlesource.com/platform/frameworks/native/+/master/include/android/configuration.h#473
    if (protoConfig.getMnc() != 0) {
      configuration.setNetworkCodeQualifier(
          NetworkCodeQualifier.getQualifier(
              String.format(
                  Locale.US,
                  "mnc%1$03d",
                  protoConfig.getMnc() == 0xffff ? 0 : protoConfig.getMnc())));
    }

    if (!protoConfig.getLocale().isEmpty()) {
      // The proto stores it in a BCP-47 format, but the parser requires a b+ and all the - as +.
      // It's a nice a little impedance mismatch.
      new LocaleQualifier()
          .checkAndSet("b+" + protoConfig.getLocale().replace("-", "+"), configuration);
    }

    if (LAYOUT_DIRECTION_MAP.containsKey(protoConfig.getLayoutDirection())) {
      configuration.setLayoutDirectionQualifier(
          new LayoutDirectionQualifier(LAYOUT_DIRECTION_MAP.get(protoConfig.getLayoutDirection())));
    }

    if (protoConfig.getSmallestScreenWidthDp() > 0) {
      configuration.setSmallestScreenWidthQualifier(
          new SmallestScreenWidthQualifier(protoConfig.getSmallestScreenWidthDp()));
    }

    // screen dimension is defined if one number is greater than 0
    if (Math.max(protoConfig.getScreenHeight(), protoConfig.getScreenWidth()) > 0) {
      configuration.setScreenDimensionQualifier(
          new ScreenDimensionQualifier(
              Math.max(
                  protoConfig.getScreenHeight(),
                  protoConfig.getScreenWidth()), // biggest is always first
              Math.min(protoConfig.getScreenHeight(), protoConfig.getScreenWidth())));
    }

    if (protoConfig.getScreenWidthDp() > 0) {
      configuration.setScreenWidthQualifier(
          new ScreenWidthQualifier(protoConfig.getScreenWidthDp()));
    }

    if (protoConfig.getScreenHeightDp() > 0) {
      configuration.setScreenHeightQualifier(
          new ScreenHeightQualifier(protoConfig.getScreenHeightDp()));
    }

    if (LAYOUT_SIZE_MAP.containsKey(protoConfig.getScreenLayoutSize())) {
      configuration.setScreenSizeQualifier(
          new ScreenSizeQualifier(LAYOUT_SIZE_MAP.get(protoConfig.getScreenLayoutSize())));
    }

    if (SCREEN_LONG_MAP.containsKey(protoConfig.getScreenLayoutLong())) {
      configuration.setScreenRatioQualifier(
          new ScreenRatioQualifier(SCREEN_LONG_MAP.get(protoConfig.getScreenLayoutLong())));
    }

    if (SCREEN_ROUND_MAP.containsKey(protoConfig.getScreenRound())) {
      configuration.setScreenRoundQualifier(
          new ScreenRoundQualifier(SCREEN_ROUND_MAP.get(protoConfig.getScreenRound())));
    }

    if (SCREEN_ORIENTATION_MAP.containsKey(protoConfig.getOrientation())) {
      configuration.setScreenOrientationQualifier(
          new ScreenOrientationQualifier(SCREEN_ORIENTATION_MAP.get(protoConfig.getOrientation())));
    }

    if (SCREEN_UI_MODE.containsKey(protoConfig.getUiModeType())) {
      configuration.setUiModeQualifier(
          new UiModeQualifier(SCREEN_UI_MODE.get(protoConfig.getUiModeType())));
    }

    if (NIGHT_MODE_MAP.containsKey(protoConfig.getUiModeNight())) {
      configuration.setNightModeQualifier(
          new NightModeQualifier(NIGHT_MODE_MAP.get(protoConfig.getUiModeNight())));
    }

    if (DENSITY_MAP.containsKey(protoConfig.getDensity())) {
      configuration.setDensityQualifier(
          new DensityQualifier(DENSITY_MAP.get(protoConfig.getDensity())));
    }

    if (TOUCH_TYPE_MAP.containsKey(protoConfig.getTouchscreen())) {
      configuration.setTouchTypeQualifier(
          new TouchScreenQualifier(TOUCH_TYPE_MAP.get(protoConfig.getTouchscreen())));
    }

    if (KEYBOARD_STATE_MAP.containsKey(protoConfig.getKeysHidden())) {
      configuration.setKeyboardStateQualifier(
          new KeyboardStateQualifier(KEYBOARD_STATE_MAP.get(protoConfig.getKeysHidden())));
    }

    if (KEYBOARD_MAP.containsKey(protoConfig.getKeyboard())) {
      configuration.setTextInputMethodQualifier(
          new TextInputMethodQualifier(KEYBOARD_MAP.get(protoConfig.getKeyboard())));
    }

    if (NAV_STATE_MAP.containsKey(protoConfig.getNavHidden())) {
      configuration.setNavigationStateQualifier(
          new NavigationStateQualifier(NAV_STATE_MAP.get(protoConfig.getNavHidden())));
    }

    if (NAVIGATION_MAP.containsKey(protoConfig.getNavigation())) {
      configuration.setNavigationMethodQualifier(
          new NavigationMethodQualifier(NAVIGATION_MAP.get(protoConfig.getNavigation())));
    }

    if (protoConfig.getSdkVersion() > 0) {
      configuration.setVersionQualifier(new VersionQualifier(protoConfig.getSdkVersion()));
    }

    return Arrays.stream(configuration.getQualifiers())
        .map(ResourceQualifier::getFolderSegment)
        .collect(toList());
  }

  /**
   * Forwards compiled resource data files to consumers, extracting any embedded IDs along the way.
   */
  private static void consumeCompiledFile(
      DependencyInfo dependencyInfo,
      KeyValueConsumers consumers,
      CompiledFileWithData compiledFileWithData,
      VisibilityRegistry registry)
      throws InvalidProtocolBufferException {
    CompiledFile compiledFile = compiledFileWithData.compiledFile();
    Path sourcePath = Paths.get(compiledFile.getSourcePath());
    DataSource dataSource = DataSource.of(dependencyInfo, sourcePath);
    ResourceName resourceName = ResourceName.parse(compiledFile.getResourceName());
    FullyQualifiedName fqn =
        FullyQualifiedName.of(
            resourceName.pkg(),
            convertToQualifiers(compiledFile.getConfig()),
            resourceName.type(),
            resourceName.entry());

    consumers.overwritingConsumer.accept(
        fqn,
        DataValueFile.of(
            registry.getVisibility(resourceName),
            dataSource,
            compiledFileWithData.fingerprint(),
            compiledFileWithData.rootXmlNode()));

    for (CompiledFile.Symbol exportedSymbol : compiledFile.getExportedSymbolList()) {
      if (exportedSymbol.getResourceName().startsWith("android:")) {
        // Skip writing resource xml's for resources in the sdk
        continue;
      }

      ResourceName exportedResourceName = ResourceName.parse(exportedSymbol.getResourceName());
      verify(exportedResourceName.type() == ResourceType.ID);
      FullyQualifiedName symbolFqn =
          FullyQualifiedName.of(
              exportedResourceName.pkg(),
              /*qualifiers=*/ ImmutableList.of(),
              exportedResourceName.type(),
              exportedResourceName.entry());

      DataResourceXml dataResourceXml =
          DataResourceXml.from(
              Value.getDefaultInstance(),
              registry.getVisibility(exportedResourceName),
              dataSource,
              ResourceType.ID,
              /*packageResolver=*/ null);
      consumers.combiningConsumer.accept(symbolFqn, dataResourceXml);
    }
  }

  private static void readAttributesFile(
      DependencyInfo dependencyInfo,
      InputStream resourceFileStream,
      FileSystem fileSystem,
      ParsedAndroidData.KeyValueConsumer<DataKey, DataResource> combine,
      ParsedAndroidData.KeyValueConsumer<DataKey, DataResource> overwrite)
      throws IOException {

    Header header = Header.parseDelimitedFrom(resourceFileStream);
    List<FullyQualifiedName> fullyQualifiedNames = new ArrayList<>();
    for (int i = 0; i < header.getEntryCount(); i++) {
      SerializeFormat.DataKey protoKey =
          SerializeFormat.DataKey.parseDelimitedFrom(resourceFileStream);
      fullyQualifiedNames.add(FullyQualifiedName.fromProto(protoKey));
    }

    DataSourceTable sourceTable =
        DataSourceTable.read(dependencyInfo, resourceFileStream, fileSystem, header);

    for (FullyQualifiedName fullyQualifiedName : fullyQualifiedNames) {
      SerializeFormat.DataValue protoValue =
          SerializeFormat.DataValue.parseDelimitedFrom(resourceFileStream);
      DataSource source = sourceTable.sourceFromId(protoValue.getSourceId());
      DataResourceXml dataResourceXml = (DataResourceXml) DataResourceXml.from(protoValue, source);
      AttributeType attributeType = AttributeType.valueOf(protoValue.getXmlValue().getValueType());

      if (attributeType.isCombining()) {
        combine.accept(fullyQualifiedName, dataResourceXml);
      } else {
        overwrite.accept(fullyQualifiedName, dataResourceXml);
      }
    }
  }

  public static Map<DataKey, DataResource> readAttributes(CompiledResources resources) {
    try (ZipInputStream zipStream = new ZipInputStream(Files.newInputStream(resources.getZip()))) {
      Map<DataKey, DataResource> attributes = new LinkedHashMap<>();
      for (ZipEntry entry = zipStream.getNextEntry();
          entry != null;
          entry = zipStream.getNextEntry()) {
        if (entry.getName().endsWith(CompiledResources.ATTRIBUTES_FILE_EXTENSION)) {
          readAttributesFile(
              // Don't care about origin of ".attributes" values, since they don't feed into field
              // initializers.
              DependencyInfo.UNKNOWN,
              zipStream,
              FileSystems.getDefault(),
              (key, value) ->
                  attributes.put(
                      key,
                      attributes.containsKey(key) ? attributes.get(key).combineWith(value) : value),
              (key, value) ->
                  attributes.put(
                      key,
                      attributes.containsKey(key) ? attributes.get(key).overwrite(value) : value));
        }
      }
      return attributes;
    } catch (IOException e) {
      throw new DeserializationException(e);
    }
  }

  @VisibleForTesting
  public static void readTable(
      DependencyInfo dependencyInfo, InputStream in, KeyValueConsumers consumers)
      throws IOException {
    final ResourceTable resourceTable =
        ResourceTable.parseFrom(in, ExtensionRegistry.getEmptyRegistry());
    consumeResourceTable(
        dependencyInfo,
        consumers,
        resourceTable,
        new VisibilityRegistry(ImmutableSet.of(), ImmutableSet.of()));
  }

  public Map<DataKey, DataResource> read(DependencyInfo dependencyInfo, Path inPath) {
    Map<DataKey, DataResource> resources = new LinkedHashMap<>();
    read(
        dependencyInfo,
        inPath,
        KeyValueConsumers.of(
            resources::put,
            resources::put,
            (key, value) -> {
              throw new IllegalStateException(String.format("Unexpected asset in %s", inPath));
            }));
    return resources;
  }

  @Override
  public void read(DependencyInfo dependencyInfo, Path inPath, KeyValueConsumers consumers) {
    Stopwatch timer = Stopwatch.createStarted();

    try (ZipFile zipFile = new ZipFile(inPath.toFile())) {
      ResourceContainer resourceContainer =
          readResourceContainer(zipFile, includeFileContentsForValidation);
      VisibilityRegistry registry = computeResourceVisibility(resourceContainer);

      for (ResourceTable resourceTable : resourceContainer.resourceTables()) {
        consumeResourceTable(dependencyInfo, consumers, resourceTable, registry);
      }
      for (CompiledFileWithData compiledFile : resourceContainer.compiledFiles()) {
        consumeCompiledFile(dependencyInfo, consumers, compiledFile, registry);
      }

      Enumeration<? extends ZipEntry> resourceFiles = zipFile.entries();
      while (resourceFiles.hasMoreElements()) {
        ZipEntry resourceFile = resourceFiles.nextElement();
        String fileZipPath = resourceFile.getName();

        try (InputStream resourceFileStream = zipFile.getInputStream(resourceFile)) {
          if (fileZipPath.endsWith(CompiledResources.ATTRIBUTES_FILE_EXTENSION)) {
            readAttributesFile(
                dependencyInfo,
                resourceFileStream,
                inPath.getFileSystem(),
                consumers.combiningConsumer,
                consumers.overwritingConsumer);
          }
        }
      }
    } catch (IOException e) {
      throw new DeserializationException("Error deserializing " + inPath, e);
    } finally {
      logger.fine(
          String.format(
              "Deserialized in compiled merged in %sms", timer.elapsed(TimeUnit.MILLISECONDS)));
    }
  }

  private static ResourceContainer readResourceContainer(
      ZipFile zipFile, boolean includeFileContentsForValidation) throws IOException {
    List<ResourceTable> resourceTables = new ArrayList<>();
    List<CompiledFileWithData> compiledFiles = new ArrayList<>();

    Enumeration<? extends ZipEntry> resourceFiles = zipFile.entries();
    while (resourceFiles.hasMoreElements()) {
      ZipEntry resourceFile = resourceFiles.nextElement();
      String fileZipPath = resourceFile.getName();
      if (fileZipPath.endsWith(CompiledResources.ATTRIBUTES_FILE_EXTENSION)) {
        continue;
      }

      try (LittleEndianDataInputStream dataInputStream =
          new LittleEndianDataInputStream(zipFile.getInputStream(resourceFile))) {
        int magic = dataInputStream.readInt();
        verify(
            magic == AAPT_CONTAINER_MAGIC,
            "Unexpected magic number in %s: %s",
            resourceFile,
            magic);
        int version = dataInputStream.readInt();
        verify(
            version == AAPT_CONTAINER_VERSION,
            "Unexpected version number in %s: %s",
            resourceFile,
            version);

        int numberOfEntries = dataInputStream.readInt();
        for (int i = 0; i < numberOfEntries; i++) {
          int entryType = dataInputStream.readInt();
          verify(
              entryType == AAPT_CONTAINER_ENTRY_RES_TABLE
                  || entryType == AAPT_CONTAINER_ENTRY_RES_FILE,
              "Unexpected entry type in %s: %s",
              resourceFile,
              entryType);

          if (entryType == AAPT_CONTAINER_ENTRY_RES_TABLE) {
            long size = dataInputStream.readLong();
            verify(size <= Integer.MAX_VALUE);
            byte[] tableBytes = readBytesAndSkipPadding(dataInputStream, (int) size);

            resourceTables.add(
                ResourceTable.parseFrom(tableBytes, ExtensionRegistry.getEmptyRegistry()));
          } else {
            // useless and wrong "size" data; see
            // https://android-review.googlesource.com/c/platform/frameworks/base/+/1161789
            ByteStreams.skipFully(dataInputStream, 8);
            int headerSize = dataInputStream.readInt();
            long payloadSize = dataInputStream.readLong();
            byte[] headerBytes = readBytesAndSkipPadding(dataInputStream, headerSize);

            CompiledFile compiledFile =
                CompiledFile.parseFrom(headerBytes, ExtensionRegistry.getEmptyRegistry());

            HashCode fingerprint;
            XmlNode rootXmlNode;
            if (includeFileContentsForValidation) {
              byte[] payloadBytes = readBytesAndSkipPadding(dataInputStream, (int) payloadSize);
              fingerprint = Hashing.goodFastHash(/*minimumBits=*/ 64).hashBytes(payloadBytes);
              rootXmlNode =
                  compiledFile.getType() == Resources.FileReference.Type.PROTO_XML
                      ? XmlNode.parseFrom(payloadBytes, ExtensionRegistry.getEmptyRegistry())
                      : null;
            } else {
              ByteStreams.skipFully(
                  dataInputStream, roundUpToNearest(payloadSize, AAPT_FLAT_FILE_ALIGNMENT));
              fingerprint = null;
              rootXmlNode = null;
            }

            compiledFiles.add(CompiledFileWithData.create(compiledFile, fingerprint, rootXmlNode));
          }
        }
      }
    }
    return ResourceContainer.create(resourceTables, compiledFiles);
  }

  /** Rounds {@code n} up to the nearest multiple of {@code k}. */
  private static long roundUpToNearest(long n, int k) {
    return ((n + k - 1) / k) * k;
  }

  /**
   * Classifies resources as public/private/unknown.
   *
   * <p>Per https://developer.android.com/studio/projects/android-library#PrivateResources, the
   * {@code <public/>} tag marks a resource as public with all others inferred to be private. Since
   * {@code android_library} allows mixing directories (with varying ownership) in {@code
   * resource_files} (b/148110689), we perform the classification on a per-directory basis, so that
   * marking something in {@code foo/res} public has no impact on {@code bar/res}.
   */
  private static VisibilityRegistry computeResourceVisibility(ResourceContainer resourceContainer)
      throws UnsupportedEncodingException {
    if (!ResourceCompiler.USE_VISIBILITY_FROM_AAPT2) {
      return new VisibilityRegistry(ImmutableSet.of(), ImmutableSet.of());
    }

    // decode source pools ahead of time to avoid having to repeatedly do so later.
    List<List<String>> sourcePools = new ArrayList<>();
    for (ResourceTable resourceTable : resourceContainer.resourceTables()) {
      sourcePools.add(decodeSourcePool(resourceTable.getSourcePool().getData().toByteArray()));
    }

    PublicResources publicResources = findExplicitlyPublicResources(resourceContainer, sourcePools);
    return new VisibilityRegistry(
        publicResources.explicitlyPublicResources(),
        findImpliedPrivateResources(resourceContainer, sourcePools, publicResources));
  }

  private static PublicResources findExplicitlyPublicResources(
      ResourceContainer resourceContainer, List<List<String>> sourcePools) {
    Set<ResourceName> explicitlyPublicResources = new HashSet<>();
    Set<Path> directoriesWithPublicResources = new HashSet<>();
    for (int i = 0; i < resourceContainer.resourceTables().size(); i++) {
      ResourceTable resourceTable = resourceContainer.resourceTables().get(i);
      List<String> sourcePool = sourcePools.get(i);

      for (Package pkg : resourceTable.getPackageList()) {
        for (Resources.Type type : pkg.getTypeList()) {
          ResourceType resourceType = ResourceType.getEnum(type.getName());
          for (Resources.Entry entry : type.getEntryList()) {
            if (entry.getVisibility().getLevel() != Resources.Visibility.Level.PUBLIC) {
              continue;
            }

            explicitlyPublicResources.add(
                ResourceName.create(pkg.getPackageName(), resourceType, entry.getName()));
            directoriesWithPublicResources.add(
                getNormalizedResourceDirectory(
                    sourcePool.get(entry.getVisibility().getSource().getPathIdx())));
          }
        }
      }
    }
    return PublicResources.create(explicitlyPublicResources, directoriesWithPublicResources);
  }

  private static ImmutableSet<ResourceName> findImpliedPrivateResources(
      ResourceContainer resourceContainer,
      List<List<String>> sourcePools,
      PublicResources publicResources) {
    Set<ResourceName> explicitlyPublicResources = publicResources.explicitlyPublicResources();
    Set<Path> directoriesWithPublicResources = publicResources.directoriesWithPublicResources();
    Set<ResourceName> impliedPrivateResources = new HashSet<>();
    for (int i = 0; i < resourceContainer.resourceTables().size(); i++) {
      ResourceTable resourceTable = resourceContainer.resourceTables().get(i);
      List<String> sourcePool = sourcePools.get(i);

      for (Package pkg : resourceTable.getPackageList()) {
        for (Resources.Type type : pkg.getTypeList()) {
          ResourceType resourceType = ResourceType.getEnum(type.getName());
          for (Resources.Entry entry : type.getEntryList()) {
            if (entry.getVisibility().getLevel() == Resources.Visibility.Level.PUBLIC) {
              continue;
            }

            ResourceName resourceName =
                ResourceName.create(pkg.getPackageName(), resourceType, entry.getName());
            if (explicitlyPublicResources.contains(resourceName)
                || impliedPrivateResources.contains(resourceName)) {
              continue; // we already figured out a classification for this resource.
            }

            boolean inDirectoryWithPublic =
                entry.getConfigValueList().stream()
                    .map(
                        configValue ->
                            getNormalizedResourceDirectory(
                                sourcePool.get(configValue.getValue().getSource().getPathIdx())))
                    .anyMatch(directoriesWithPublicResources::contains);
            if (inDirectoryWithPublic) {
              impliedPrivateResources.add(resourceName);
            }
          }
        }
      }
    }
    for (CompiledFileWithData compiledFileWithData : resourceContainer.compiledFiles()) {
      CompiledFile compiledFile = compiledFileWithData.compiledFile();
      ResourceName resourceName = ResourceName.parse(compiledFile.getResourceName());
      if (explicitlyPublicResources.contains(resourceName)
          || impliedPrivateResources.contains(resourceName)) {
        continue; // we already figured out a classification for this resource.
      }
      if (directoriesWithPublicResources.contains(
          getNormalizedResourceDirectory(compiledFile.getSourcePath()))) {
        impliedPrivateResources.add(resourceName);
      }
    }
    return ImmutableSet.copyOf(impliedPrivateResources);
  }

  /**
   * Returns the resource directory (e.g. {@code java/com/pkg/res/}) which contains a file,
   * stripping off any {@code blaze-*} prefix for normalization.
   */
  private static Path getNormalizedResourceDirectory(String filename) {
    Path resDir = Paths.get(filename).getParent().getParent();
    if (resDir.getName(0).toString().startsWith("blaze-")) {
      // strip off stuff like blaze-out/k8-fastbuild/bin/.
      return resDir.subpath(3, resDir.getNameCount());
    } else {
      return resDir;
    }
  }

  private static byte[] readBytesAndSkipPadding(LittleEndianDataInputStream input, int size)
      throws IOException {
    byte[] result = new byte[size];
    input.readFully(result);

    long overage = size % AAPT_FLAT_FILE_ALIGNMENT;
    if (overage != 0) {
      ByteStreams.skipFully(input, AAPT_FLAT_FILE_ALIGNMENT - overage);
    }
    return result;
  }

  private static List<String> decodeSourcePool(byte[] bytes) throws UnsupportedEncodingException {
    ByteBuffer byteBuffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN);

    int stringCount = byteBuffer.getInt(8);
    boolean isUtf8 = (byteBuffer.getInt(16) & (1 << 8)) != 0;
    int stringsStart = byteBuffer.getInt(20);
    // Position the ByteBuffer after the metadata
    byteBuffer.position(28);

    List<String> strings = new ArrayList<>();

    for (int i = 0; i < stringCount; i++) {
      int stringOffset = stringsStart + byteBuffer.getInt();

      if (isUtf8) {
        int characterCount = byteBuffer.get(stringOffset) & 0xFF;
        if ((characterCount & 0x80) != 0) {
          characterCount =
              ((characterCount & 0x7F) << 8) | (byteBuffer.get(stringOffset + 1) & 0xFF);
        }

        stringOffset += (characterCount >= 0x80 ? 2 : 1);

        int length = byteBuffer.get(stringOffset) & 0xFF;
        if ((length & 0x80) != 0) {
          length = ((length & 0x7F) << 8) | (byteBuffer.get(stringOffset + 1) & 0xFF);
        }

        stringOffset += (length >= 0x80 ? 2 : 1);

        strings.add(new String(bytes, stringOffset, length, "UTF8"));
      } else {
        // TODO(b/148817379): this next block of lines is forming an int with holes in it.
        int characterCount = byteBuffer.get(stringOffset) & 0xFFFF;
        if ((characterCount & 0x8000) != 0) {
          characterCount =
              ((characterCount & 0x7FFF) << 16) | (byteBuffer.get(stringOffset + 2) & 0xFFFF);
        }

        stringOffset += 2 * (characterCount >= 0x8000 ? 2 : 1);

        int length = byteBuffer.get(stringOffset) & 0xFFFF;
        if ((length & 0x8000) != 0) {
          length = ((length & 0x7FFF) << 16) | (byteBuffer.get(stringOffset + 2) & 0xFFFF);
        }

        stringOffset += 2 * (length >= 0x8000 ? 2 : 1);

        strings.add(new String(bytes, stringOffset, length, "UTF16"));
      }
    }

    return strings;
  }

  @AutoValue
  abstract static class ResourceContainer {
    abstract ImmutableList<ResourceTable> resourceTables();

    abstract ImmutableList<CompiledFileWithData> compiledFiles();

    static ResourceContainer create(
        List<ResourceTable> resourceTables, List<CompiledFileWithData> compiledFiles) {
      return new AutoValue_AndroidCompiledDataDeserializer_ResourceContainer(
          ImmutableList.copyOf(resourceTables), ImmutableList.copyOf(compiledFiles));
    }
  }

  @AutoValue
  abstract static class CompiledFileWithData {
    abstract CompiledFile compiledFile();

    @Nullable
    abstract HashCode fingerprint();

    @Nullable
    abstract XmlNode rootXmlNode();

    static CompiledFileWithData create(
        CompiledFile compiledFile, @Nullable HashCode fingerprint, @Nullable XmlNode xmlNode) {
      return new AutoValue_AndroidCompiledDataDeserializer_CompiledFileWithData(
          compiledFile, fingerprint, xmlNode);
    }
  }

  /**
   * Wraps metadata regarding {@code <public/>} tags and their locations to help work around targets
   * which mix res/ directories they own with ones they don't (b/148110689).
   */
  @AutoValue
  abstract static class PublicResources {
    abstract ImmutableSet<ResourceName> explicitlyPublicResources();

    abstract ImmutableSet<Path> directoriesWithPublicResources();

    static PublicResources create(
        Set<ResourceName> explicitlyPublicResources, Set<Path> directoriesWithPublicResources) {
      return new AutoValue_AndroidCompiledDataDeserializer_PublicResources(
          ImmutableSet.copyOf(explicitlyPublicResources),
          ImmutableSet.copyOf(directoriesWithPublicResources));
    }
  }

  private static class VisibilityRegistry {
    private final Set<ResourceName> explicitlyPublicResources;
    private final Set<ResourceName> impliedPrivateResources;

    VisibilityRegistry(
        Set<ResourceName> explicitlyPublicResources, Set<ResourceName> impliedPrivateResources) {
      this.explicitlyPublicResources = ImmutableSet.copyOf(explicitlyPublicResources);
      this.impliedPrivateResources = ImmutableSet.copyOf(impliedPrivateResources);
    }

    Visibility getVisibility(ResourceName resourceName) {
      if (explicitlyPublicResources.contains(resourceName)) {
        return Visibility.PUBLIC;
      }
      // TODO(b/146647897): make resources private by default
      return impliedPrivateResources.contains(resourceName)
          ? Visibility.PRIVATE
          : Visibility.UNKNOWN;
    }
  }
}
