diff --git a/.github/workflows/maps-resource-integrity.yml b/.github/workflows/maps-resource-integrity.yml
new file mode 100644
index 00000000..6621cabc
--- /dev/null
+++ b/.github/workflows/maps-resource-integrity.yml
@@ -0,0 +1,74 @@
+name: Maps Resource Integrity
+
+on:
+ pull_request:
+ paths:
+ - ".github/workflows/maps-resource-integrity.yml"
+ - "Directory.Build.props"
+ - "global.json"
+ - "scripts/check-maps-consumers.sh"
+ - "scripts/check-maps-resource-manifest.py"
+ - "source/Google/Maps/**"
+ push:
+ branches:
+ - main
+ paths:
+ - ".github/workflows/maps-resource-integrity.yml"
+ - "Directory.Build.props"
+ - "global.json"
+ - "scripts/check-maps-consumers.sh"
+ - "scripts/check-maps-resource-manifest.py"
+ - "source/Google/Maps/**"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ manifest:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Compare Maps targets with the upstream archive
+ run: python3 scripts/check-maps-resource-manifest.py
+
+ consumers:
+ needs: manifest
+ runs-on: macos-15
+ timeout-minutes: 45
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Toolchain versions
+ run: |
+ xcodebuild -version
+ xcode-select -p
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Restore .NET workload
+ run: dotnet workload restore source/Google/Maps/Maps.csproj
+
+ - name: Restore Maps package
+ run: dotnet restore source/Google/Maps/Maps.csproj
+
+ - name: Pack Maps
+ run: dotnet pack source/Google/Maps/Maps.csproj --configuration Release --no-restore --output output
+
+ - name: Direct and transitive consumer checks
+ run: scripts/check-maps-consumers.sh --package-dir output --allow-xcode-mismatch
+
+ - name: Upload diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: maps-resource-integrity-diagnostics
+ path: artifacts/maps-resource-integrity/
+ if-no-files-found: ignore
diff --git a/scripts/check-maps-consumers.sh b/scripts/check-maps-consumers.sh
new file mode 100755
index 00000000..3bb9c383
--- /dev/null
+++ b/scripts/check-maps-consumers.sh
@@ -0,0 +1,396 @@
+#!/bin/zsh
+set -euo pipefail
+
+usage() {
+ cat <<'EOF'
+Usage: scripts/check-maps-consumers.sh [options]
+
+ --package-dir
Local NuGet feed (default: output)
+ --package-version AdamE.Google.iOS.Maps version to consume
+ --allow-xcode-mismatch Pass ValidateXcodeVersion=false for local diagnostics
+ --keep-work Retain the temporary consumer projects after a failure
+
+Builds direct and transitive net10.0-ios consumers of the locally packed Maps package. Verifies
+the native symbol is linked and the app contains exactly one complete GoogleMaps.bundle.
+EOF
+}
+
+repo_root="$(cd "$(dirname "$0")/.." && pwd)"
+source_targets="$repo_root/source/Google/Maps/Maps.targets"
+package_id="AdamE.Google.iOS.Maps"
+resource_bundle="GoogleMaps.bundle"
+package_dir="$repo_root/output"
+package_version=""
+allow_xcode_mismatch="false"
+keep_work="false"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --package-dir) package_dir="$2"; shift 2 ;;
+ --package-version) package_version="$2"; shift 2 ;;
+ --allow-xcode-mismatch) allow_xcode_mismatch="true"; shift ;;
+ --keep-work) keep_work="true"; shift ;;
+ --help|-h) usage; exit 0 ;;
+ *) echo "Unknown argument: $1" >&2; usage >&2; exit 1 ;;
+ esac
+done
+
+[[ "$package_dir" != /* ]] && package_dir="$repo_root/$package_dir"
+
+if [[ -n "$package_version" ]]; then
+ nupkg="$package_dir/$package_id.$package_version.nupkg"
+else
+ nupkgs=("$package_dir"/"$package_id".*.nupkg(N))
+ if (( ${#nupkgs[@]} > 1 )); then
+ echo "Multiple $package_id packages found in $package_dir; pass --package-version." >&2
+ printf ' %s\n' "${nupkgs[@]}" >&2
+ exit 1
+ fi
+ nupkg="${nupkgs[1]:-}"
+ package_version="${${nupkg:t}#$package_id.}"
+ package_version="${package_version%.nupkg}"
+fi
+
+if [[ -z "${nupkg:-}" || ! -f "$nupkg" ]]; then
+ echo "No $package_id package found in $package_dir" >&2
+ exit 1
+fi
+
+echo "Testing $package_id $package_version from $nupkg"
+
+# NuGet treats /var and /private/var as different project identities even though one is a symlink.
+# Resolve mktemp's result so the ProjectReference graph in the transitive shape remains connected.
+work="$(cd "$(mktemp -d)" && pwd -P)"
+# Maps.targets concatenates this property while it is evaluated, before XBD normalizes it.
+xbd_dir="$work/xbd/"
+packages_dir="$work/packages"
+artifacts_dir="$repo_root/artifacts/maps-resource-integrity"
+mkdir -p "$xbd_dir" "$packages_dir"
+# Keep the generated projects on the same pinned SDK/workload set as the repository. The
+# .NET SDK resolves global.json from the project tree, not from this script's working directory.
+cp "$repo_root/global.json" "$work/global.json"
+
+failures=0
+completed="false"
+pass() { print -r -- " PASS $1"; }
+fail() { print -r -- " FAIL $1" >&2; failures=$((failures + 1)); }
+
+cleanup() {
+ local exit_status=$?
+
+ if [[ "$completed" != "true" ]]; then
+ mkdir -p "$artifacts_dir"
+ cp "$work"/*.diff(N) "$work"/*.log(N) "$work"/*.txt(N) "$artifacts_dir/" 2>/dev/null || true
+ print -r -- "Diagnostics copied to $artifacts_dir" >&2
+ fi
+
+ if [[ "$completed" != "true" && "$keep_work" == "true" ]]; then
+ print -r -- "Diagnostic scaffold kept at $work" >&2
+ else
+ rm -rf "$work"
+ fi
+
+ return "$exit_status"
+}
+trap cleanup EXIT
+
+echo
+echo "Packaged MSBuild integration"
+for folder in build buildTransitive; do
+ packaged_targets="$work/$folder.targets"
+ if unzip -p "$nupkg" "$folder/$package_id.targets" > "$packaged_targets" 2>/dev/null; then
+ if cmp -s "$source_targets" "$packaged_targets"; then
+ pass "$folder/$package_id.targets matches source"
+ else
+ fail "$folder/$package_id.targets differs from source/Google/Maps/Maps.targets"
+ fi
+ else
+ fail "$folder/$package_id.targets is missing from the package"
+ fi
+done
+
+if (( failures > 0 )); then
+ echo "$failures package integration check(s) failed." >&2
+ exit 1
+fi
+
+cat > "$work/NuGet.config" <
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+EOF
+
+app_properties='
+ net10.0-ios
+ Exe
+ enable
+ 15.0
+ iossimulator-arm64
+ iPhoneSimulator
+ false
+ manual'
+
+msbuild_args=("-p:XamarinBuildDownloadDir=$xbd_dir")
+[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false")
+
+write_app_sources() {
+ local directory="$1"
+ local distance_expression="$2"
+ mkdir -p "$directory"
+
+ cat > "$directory/Main.cs" <<'EOF'
+using UIKit;
+UIApplication.Main(args, null, typeof(MapsConsumer.AppDelegate));
+EOF
+
+ cat > "$directory/AppDelegate.cs" < "$directory/Info.plist" <<'EOF'
+
+
+
+
+ CFBundleIdentifier
+ com.googleapisforioscomponents.tests.mapsconsumer
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ 1
+
+
+EOF
+}
+
+expected_manifest="$work/expected-bundle-files.txt"
+
+locate_upstream_bundle() {
+ local matches count
+ matches="$(find "$xbd_dir" -type d -path '*/Maps/Resources/GoogleMapsResources/GoogleMaps.bundle' -print)"
+ count="$(print -r -- "$matches" | sed '/^$/d' | wc -l | tr -d ' ')"
+ if [[ "$count" != "1" ]]; then
+ print -r -- "expected one extracted upstream GoogleMaps.bundle, found $count" >&2
+ return 1
+ fi
+ print -r -- "$matches"
+}
+
+write_file_manifest() {
+ local root="$1"
+ local destination="$2"
+ (cd "$root" && find . -type f -print | sed 's|^\./||' | LC_ALL=C sort) > "$destination"
+}
+
+assert_app() {
+ local shape="$1"
+ local app_path="$2"
+ local upstream_bundle bundle_count app_bundle actual_manifest content_mismatches
+ local binary_name binary symbol_count relative_path
+
+ if [[ -z "$app_path" || ! -d "$app_path" ]]; then
+ fail "$shape: app bundle was not produced"
+ return
+ fi
+
+ if ! upstream_bundle="$(locate_upstream_bundle)"; then
+ fail "$shape: could not resolve the extracted upstream resource bundle"
+ return
+ fi
+ if [[ ! -s "$expected_manifest" ]]; then
+ write_file_manifest "$upstream_bundle" "$expected_manifest"
+ fi
+
+ bundle_count="$(find "$app_path" -type d -name "$resource_bundle" -print | wc -l | tr -d ' ')"
+ if [[ "$bundle_count" != "1" ]]; then
+ fail "$shape: expected exactly one $resource_bundle, found $bundle_count"
+ return
+ fi
+
+ app_bundle="$(find "$app_path" -type d -name "$resource_bundle" -print -quit)"
+ if [[ "$app_bundle" != "$app_path/$resource_bundle" ]]; then
+ fail "$shape: $resource_bundle is not at the app root ($app_bundle)"
+ else
+ pass "$shape: exactly one root $resource_bundle is present"
+ fi
+
+ actual_manifest="$work/$shape-bundle-files.txt"
+ write_file_manifest "$app_bundle" "$actual_manifest"
+ if diff -u "$expected_manifest" "$actual_manifest" > "$work/$shape-bundle.diff"; then
+ pass "$shape: bundle file set matches upstream ($(wc -l < "$actual_manifest" | tr -d ' ') files)"
+
+ content_mismatches="$work/$shape-content-mismatches.txt"
+ : > "$content_mismatches"
+ while IFS= read -r relative_path; do
+ if ! cmp -s "$upstream_bundle/$relative_path" "$app_bundle/$relative_path"; then
+ print -r -- "$relative_path" >> "$content_mismatches"
+ fi
+ done < "$expected_manifest"
+
+ if [[ -s "$content_mismatches" ]]; then
+ fail "$shape: bundle contents differ from upstream (see $content_mismatches)"
+ else
+ pass "$shape: bundle contents are byte-for-byte identical to upstream"
+ fi
+ else
+ fail "$shape: bundle file set differs from upstream (see $work/$shape-bundle.diff)"
+ fi
+
+ binary_name="$(/usr/bin/plutil -extract CFBundleExecutable raw -o - "$app_path/Info.plist" 2>/dev/null || true)"
+ [[ -z "$binary_name" ]] && binary_name="${${app_path:t}%.app}"
+ binary="$app_path/$binary_name"
+ if [[ ! -f "$binary" ]]; then
+ fail "$shape: app executable is missing at $binary"
+ return
+ fi
+
+ symbol_count="$(nm -U "$binary" 2>/dev/null | awk '$NF == "_GMSGeometryDistance" { count++ } END { print count + 0 }')"
+ if [[ "${symbol_count:-0}" -gt 0 ]]; then
+ pass "$shape: GMSGeometryDistance is linked into the app"
+ else
+ fail "$shape: GMSGeometryDistance is absent from the app binary"
+ fi
+
+ if find "$app_path/Frameworks" -type d -name 'GoogleMaps.framework' -print -quit 2>/dev/null | grep -c . >/dev/null; then
+ fail "$shape: static GoogleMaps.framework was unexpectedly copied into App.app/Frameworks"
+ else
+ pass "$shape: no dynamic GoogleMaps.framework copy is present"
+ fi
+}
+
+build_and_assert() {
+ local shape="$1"
+ local project="$2"
+ local assembly_name="$3"
+ local project_directory="${project:h}"
+ local log="$work/$shape.log"
+ local restored_nupkg="$packages_dir/${package_id:l}/$package_version/${package_id:l}.$package_version.nupkg"
+
+ if ! dotnet restore "$project" \
+ --configfile "$work/NuGet.config" \
+ --packages "$packages_dir" \
+ "${msbuild_args[@]}" > "$log" 2>&1; then
+ fail "$shape: restore failed (see $log)"
+ tail -25 "$log" >&2
+ return
+ fi
+
+ if [[ ! -f "$restored_nupkg" ]]; then
+ fail "$shape: the restored package is missing at $restored_nupkg"
+ return
+ elif ! cmp -s "$nupkg" "$restored_nupkg"; then
+ fail "$shape: restore did not consume the locally packed Maps package"
+ return
+ else
+ pass "$shape: restore consumed the locally packed Maps package"
+ fi
+
+ if ! dotnet build "$project" \
+ --configuration Debug \
+ --no-restore \
+ "${msbuild_args[@]}" >> "$log" 2>&1; then
+ fail "$shape: build failed (see $log)"
+ tail -25 "$log" >&2
+ return
+ fi
+
+ assert_app "$shape" "$(find "$project_directory/bin" -type d -name "$assembly_name.app" -print -quit)"
+}
+
+echo
+echo "Shape: direct (app -> package)"
+direct="$work/direct"
+write_app_sources "$direct" 'Google.Maps.GeometryUtils.Distance(new CoreLocation.CLLocationCoordinate2D(0, 0), new CoreLocation.CLLocationCoordinate2D(1, 1))'
+cat > "$direct/DirectApp.csproj" <
+ $app_properties
+ DirectApp
+ MapsConsumer
+
+
+
+
+
+EOF
+build_and_assert "direct" "$direct/DirectApp.csproj" "DirectApp"
+
+echo
+echo "Shape: library (app -> class library -> package)"
+library_root="$work/library"
+mkdir -p "$library_root/Lib"
+cat > "$library_root/Lib/MapsLib.csproj" <
+
+ net10.0-ios
+ Library
+ enable
+ 15.0
+ false
+
+
+
+
+
+EOF
+cat > "$library_root/Lib/Probe.cs" <<'EOF'
+namespace MapsConsumer.Library;
+
+public static class Probe
+{
+ public static double Distance() => Google.Maps.GeometryUtils.Distance(
+ new CoreLocation.CLLocationCoordinate2D(0, 0),
+ new CoreLocation.CLLocationCoordinate2D(1, 1));
+}
+EOF
+
+write_app_sources "$library_root/App" 'MapsConsumer.Library.Probe.Distance()'
+cat > "$library_root/App/LibraryApp.csproj" <
+ $app_properties
+ LibraryApp
+ MapsConsumer
+
+
+
+
+
+EOF
+build_and_assert "library" "$library_root/App/LibraryApp.csproj" "LibraryApp"
+
+echo
+if (( failures > 0 )); then
+ echo "$failures Maps consumer check(s) failed." >&2
+ exit 1
+fi
+
+completed="true"
+echo "All Maps consumer checks passed."
diff --git a/scripts/check-maps-resource-manifest.py b/scripts/check-maps-resource-manifest.py
new file mode 100755
index 00000000..e993efdf
--- /dev/null
+++ b/scripts/check-maps-resource-manifest.py
@@ -0,0 +1,338 @@
+#!/usr/bin/env python3
+"""Validate Google Maps BundleResource declarations against the pinned SDK archive."""
+
+from __future__ import annotations
+
+import argparse
+from collections import Counter
+import hashlib
+from pathlib import Path, PurePosixPath
+import shutil
+import sys
+import tarfile
+import tempfile
+import time
+from urllib.error import URLError
+from urllib.request import Request, urlopen
+import xml.etree.ElementTree as ET
+
+
+EXPECTED_ARCHIVE_SHA256 = (
+ "81bbd92c2d627087ae222ae955e5f746590812d7389b9d800add15e4004b6431"
+)
+RESOURCE_PROPERTY = "_GoogleMapsResourcesBaseFolder"
+RESOURCE_TOKEN = f"$({RESOURCE_PROPERTY})"
+LOGICAL_ROOT = "GoogleMaps.bundle"
+RESTORE_TARGET = "_GMpsDownloadedItems"
+EXPECTED_APP_ITEM_CONDITION = "('$(OutputType)'!='Library' OR '$(IsAppExtension)'=='True')"
+
+
+def repository_root() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def parse_arguments() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--targets",
+ type=Path,
+ default=repository_root() / "source/Google/Maps/Maps.targets",
+ help="Maps.targets to inspect (default: repository source file)",
+ )
+ parser.add_argument(
+ "--archive",
+ type=Path,
+ help="Use an existing Google Maps .tar.gz instead of downloading the declared URL",
+ )
+ return parser.parse_args()
+
+
+def elements(parent: ET.Element, name: str) -> list[ET.Element]:
+ return [element for element in parent.iter() if element.tag.rsplit("}", 1)[-1] == name]
+
+
+def direct_children(parent: ET.Element, name: str) -> list[ET.Element]:
+ return [child for child in list(parent) if child.tag.rsplit("}", 1)[-1] == name]
+
+
+def normalize_relative_path(value: str, label: str, errors: list[str]) -> str:
+ normalized = value.replace("\\", "/")
+ path = PurePosixPath(normalized)
+ if not normalized or normalized.startswith("/") or any(part in ("", ".", "..") for part in path.parts):
+ errors.append(f"{label} is not a normalized relative path: {value!r}")
+ return normalized
+
+
+def one_text(parent: ET.Element, name: str, errors: list[str]) -> str:
+ matches = elements(parent, name)
+ if len(matches) != 1 or not (matches[0].text or "").strip():
+ errors.append(f"expected exactly one non-empty {name}, found {len(matches)}")
+ return ""
+ return (matches[0].text or "").strip()
+
+
+def parse_targets(targets_path: Path) -> tuple[str, str, list[str], list[str], list[str]]:
+ errors: list[str] = []
+ try:
+ root = ET.parse(targets_path).getroot()
+ except (OSError, ET.ParseError) as exc:
+ raise RuntimeError(f"could not parse {targets_path}: {exc}") from exc
+
+ downloads = elements(root, "XamarinBuildDownload")
+ if len(downloads) != 1:
+ errors.append(f"expected exactly one XamarinBuildDownload item, found {len(downloads)}")
+ download = downloads[0] if downloads else root
+ archive_url = one_text(download, "Url", errors)
+ archive_kind = one_text(download, "Kind", errors)
+ if archive_kind and archive_kind.lower() != "tgz":
+ errors.append(f"XamarinBuildDownload Kind is {archive_kind!r}, expected 'Tgz'")
+ if archive_url and not archive_url.startswith("https://"):
+ errors.append(f"archive URL must use HTTPS: {archive_url}")
+
+ properties = elements(root, RESOURCE_PROPERTY)
+ if len(properties) != 1 or not (properties[0].text or "").strip():
+ errors.append(f"expected exactly one non-empty {RESOURCE_PROPERTY}, found {len(properties)}")
+ archive_resource_root = ""
+ else:
+ resource_base = (properties[0].text or "").strip().replace("\\", "/")
+ prefix = "$(XamarinBuildDownloadDir)$(_GoogleMapsItemsFolder)/"
+ if not resource_base.startswith(prefix):
+ errors.append(f"{RESOURCE_PROPERTY} must start with {prefix!r}: {resource_base}")
+ archive_resource_root = ""
+ else:
+ archive_resource_root = resource_base[len(prefix) :].rstrip("/")
+ if not archive_resource_root.endswith(f"/{LOGICAL_ROOT}"):
+ errors.append(
+ f"{RESOURCE_PROPERTY} must resolve to {LOGICAL_ROOT}: {archive_resource_root}"
+ )
+
+ restore_targets = [
+ target for target in direct_children(root, "Target") if target.get("Name") == RESTORE_TARGET
+ ]
+ if len(restore_targets) != 1:
+ errors.append(
+ f"expected exactly one project-level Target named {RESTORE_TARGET}, "
+ f"found {len(restore_targets)}"
+ )
+ restore_target = restore_targets[0] if restore_targets else root
+ if restore_targets and restore_target.get("Condition", "").strip():
+ errors.append(f"Target {RESTORE_TARGET} must not have a Condition")
+
+ all_restore_hooks = [
+ item
+ for item in elements(root, "XamarinBuildRestoreResources")
+ if item.get("Include") == RESTORE_TARGET
+ ]
+ if len(all_restore_hooks) != 1:
+ errors.append(
+ f"expected exactly one XamarinBuildRestoreResources hook for {RESTORE_TARGET}, "
+ f"found {len(all_restore_hooks)}"
+ )
+
+ hook_groups: list[tuple[ET.Element, ET.Element]] = []
+ for item_group in direct_children(root, "ItemGroup"):
+ for item in direct_children(item_group, "XamarinBuildRestoreResources"):
+ if item.get("Include") == RESTORE_TARGET:
+ hook_groups.append((item_group, item))
+
+ if len(hook_groups) != 1:
+ errors.append(
+ f"expected one project-level ItemGroup to schedule {RESTORE_TARGET}, "
+ f"found {len(hook_groups)}"
+ )
+ else:
+ hook_group, restore_hook = hook_groups[0]
+ if hook_group.get("Condition", "").strip() != EXPECTED_APP_ITEM_CONDITION:
+ errors.append(
+ f"{RESTORE_TARGET} ItemGroup Condition is {hook_group.get('Condition', '')!r}; "
+ f"expected {EXPECTED_APP_ITEM_CONDITION!r}"
+ )
+ if restore_hook.get("Condition", "").strip():
+ errors.append(f"XamarinBuildRestoreResources hook for {RESTORE_TARGET} must not have a Condition")
+ if downloads and download not in list(hook_group):
+ errors.append("XamarinBuildDownload and its restore hook must share the same ItemGroup")
+
+ includes: list[str] = []
+ logical_names: list[str] = []
+ bundle_resources = elements(restore_target, "BundleResource")
+ all_bundle_resources = elements(root, "BundleResource")
+ if len(bundle_resources) != len(all_bundle_resources):
+ errors.append(
+ f"all BundleResource items must be declared by {RESTORE_TARGET}; "
+ f"found {len(all_bundle_resources) - len(bundle_resources)} elsewhere"
+ )
+ if not bundle_resources:
+ errors.append(f"Target {RESTORE_TARGET} declares no BundleResource items")
+
+ for index, resource in enumerate(bundle_resources, start=1):
+ include = resource.get("Include", "")
+ if not include.startswith(RESOURCE_TOKEN):
+ errors.append(
+ f"BundleResource #{index} Include must start with {RESOURCE_TOKEN}: {include!r}"
+ )
+ include_suffix = include
+ else:
+ include_suffix = include[len(RESOURCE_TOKEN) :]
+ include_suffix = normalize_relative_path(
+ include_suffix, f"BundleResource #{index} Include", errors
+ )
+ includes.append(include_suffix)
+
+ if resource.get("Visible", "").lower() != "false":
+ errors.append(f"BundleResource #{index} must set Visible=\"False\"")
+
+ logical_elements = [
+ child for child in list(resource) if child.tag.rsplit("}", 1)[-1] == "LogicalName"
+ ]
+ if len(logical_elements) != 1 or not (logical_elements[0].text or "").strip():
+ errors.append(
+ f"BundleResource #{index} must contain exactly one non-empty LogicalName"
+ )
+ logical_name = ""
+ else:
+ logical_name = normalize_relative_path(
+ (logical_elements[0].text or "").strip(),
+ f"BundleResource #{index} LogicalName",
+ errors,
+ )
+ logical_names.append(logical_name)
+
+ expected_logical_name = f"{LOGICAL_ROOT}/{include_suffix}"
+ if logical_name and logical_name != expected_logical_name:
+ errors.append(
+ f"BundleResource #{index} maps {include_suffix!r} to {logical_name!r}; "
+ f"expected {expected_logical_name!r}"
+ )
+
+ return archive_url, archive_resource_root, includes, logical_names, errors
+
+
+def download_archive(url: str, destination: Path) -> None:
+ last_error: Exception | None = None
+ for attempt in range(1, 4):
+ try:
+ request = Request(url, headers={"User-Agent": "GoogleApisForiOSComponents-resource-audit"})
+ with urlopen(request, timeout=120) as response, destination.open("wb") as output:
+ shutil.copyfileobj(response, output)
+ return
+ except (OSError, URLError) as exc:
+ last_error = exc
+ destination.unlink(missing_ok=True)
+ if attempt < 3:
+ time.sleep(attempt * 2)
+ raise RuntimeError(f"could not download {url} after 3 attempts: {last_error}")
+
+
+def sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as source:
+ for chunk in iter(lambda: source.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def archive_resource_files(archive_path: Path, resource_root: str) -> tuple[list[str], list[str]]:
+ errors: list[str] = []
+ files: list[str] = []
+ normalized_root = resource_root.strip("/")
+ prefix = f"{normalized_root}/"
+
+ try:
+ with tarfile.open(archive_path, "r:gz") as archive:
+ for member in archive.getmembers():
+ name = member.name
+ while name.startswith("./"):
+ name = name[2:]
+ if not name.startswith(prefix):
+ continue
+ relative = name[len(prefix) :]
+ if not relative:
+ continue
+ if member.isfile():
+ files.append(normalize_relative_path(relative, "archive member", errors))
+ elif not member.isdir():
+ errors.append(f"archive contains a non-file resource member: {name}")
+ except (OSError, tarfile.TarError) as exc:
+ raise RuntimeError(f"could not inspect {archive_path}: {exc}") from exc
+
+ if not files:
+ errors.append(f"archive contains no regular files below {normalized_root}")
+ return files, errors
+
+
+def duplicate_messages(values: list[str], label: str) -> list[str]:
+ return [
+ f"duplicate {label} ({count} occurrences): {value}"
+ for value, count in sorted(Counter(values).items())
+ if count > 1
+ ]
+
+
+def compare_sets(actual: set[str], expected: set[str], actual_label: str) -> list[str]:
+ errors: list[str] = []
+ for value in sorted(expected - actual):
+ errors.append(f"missing {actual_label}: {value}")
+ for value in sorted(actual - expected):
+ errors.append(f"stale {actual_label}: {value}")
+ return errors
+
+
+def main() -> int:
+ args = parse_arguments()
+ targets_path = args.targets.resolve()
+
+ try:
+ archive_url, resource_root, includes, logical_names, errors = parse_targets(targets_path)
+ with tempfile.TemporaryDirectory(prefix="maps-resource-manifest-") as temp_dir:
+ if args.archive:
+ archive_path = args.archive.resolve()
+ else:
+ if not archive_url:
+ raise RuntimeError("cannot download archive because Maps.targets has no valid URL")
+ archive_path = Path(temp_dir) / "GoogleMaps.tar.gz"
+ download_archive(archive_url, archive_path)
+
+ actual_sha256 = sha256(archive_path)
+ if actual_sha256 != EXPECTED_ARCHIVE_SHA256:
+ errors.append(
+ f"archive SHA-256 is {actual_sha256}, expected {EXPECTED_ARCHIVE_SHA256}"
+ )
+
+ archive_files, archive_errors = archive_resource_files(archive_path, resource_root)
+ errors.extend(archive_errors)
+ except (OSError, RuntimeError) as exc:
+ print(f"Maps resource manifest check failed: {exc}", file=sys.stderr)
+ return 1
+
+ errors.extend(duplicate_messages(includes, "BundleResource Include"))
+ errors.extend(duplicate_messages(logical_names, "LogicalName"))
+ errors.extend(duplicate_messages(archive_files, "archive resource path"))
+
+ include_set = set(includes)
+ archive_set = set(archive_files)
+ errors.extend(compare_sets(include_set, archive_set, "BundleResource Include"))
+ expected_logical_names = {f"{LOGICAL_ROOT}/{path}" for path in archive_set}
+ errors.extend(
+ compare_sets(set(logical_names), expected_logical_names, "LogicalName")
+ )
+
+ print(f"Targets: {targets_path}")
+ print(f"Archive: {archive_url}")
+ print(f"SHA-256: {actual_sha256}")
+ print(
+ f"Resources: {len(includes)} declarations, {len(include_set)} unique target paths, "
+ f"{len(archive_files)} archive files"
+ )
+
+ if errors:
+ print(f"Maps resource manifest check failed with {len(errors)} error(s):", file=sys.stderr)
+ for error in errors:
+ print(f" - {error}", file=sys.stderr)
+ return 1
+
+ print("Maps resource manifest check passed.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/source/Google/Maps/Maps.targets b/source/Google/Maps/Maps.targets
index 93c58ec3..96c005e4 100644
--- a/source/Google/Maps/Maps.targets
+++ b/source/Google/Maps/Maps.targets
@@ -558,9 +558,6 @@
GoogleMaps.bundle\GMSCoreResources.bundle\en_IN.lproj\GMSCore.strings
-
- GoogleMaps.bundle\GMSCoreResources.bundle\en.lproj\GMSCore.strings
-
GoogleMaps.bundle\GMSCoreResources.bundle\es.lproj\GMSCore.strings
@@ -683,4 +680,4 @@
-
\ No newline at end of file
+