diff --git a/.github/workflows/google-foundation-validation.yml b/.github/workflows/google-foundation-validation.yml
index dc4cfd81..01cb8d39 100644
--- a/.github/workflows/google-foundation-validation.yml
+++ b/.github/workflows/google-foundation-validation.yml
@@ -10,8 +10,11 @@ on:
- "global.json"
- "Xamarin.Google.sln"
- "*.cake"
+ - "icons/googleiosmaps_128x128.png"
- "icons/googleiosplaces_128x128.png"
+ - "scripts/check-maps-resource-manifest.py"
- "source/AssemblyInfo.cs"
+ - "source/Google/Maps/**"
- "source/Google/Places/**"
- "tests/E2E/Google.Foundation/**"
- "tools/e2e/check-consumer-shapes.sh"
@@ -29,8 +32,11 @@ on:
- "global.json"
- "Xamarin.Google.sln"
- "*.cake"
+ - "icons/googleiosmaps_128x128.png"
- "icons/googleiosplaces_128x128.png"
+ - "scripts/check-maps-resource-manifest.py"
- "source/AssemblyInfo.cs"
+ - "source/Google/Maps/**"
- "source/Google/Places/**"
- "tests/E2E/Google.Foundation/**"
- "tools/e2e/check-consumer-shapes.sh"
@@ -108,6 +114,66 @@ jobs:
if: failure()
uses: actions/upload-artifact@v4
with:
- name: google-foundation-validation-diagnostics
+ name: google-foundation-places-diagnostics
+ path: tests/E2E/Google.Foundation/artifacts/
+ if-no-files-found: ignore
+
+ maps:
+ runs-on: macos-26
+ timeout-minutes: 60
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Select Xcode 26.6
+ run: |
+ sudo xcode-select --switch /Applications/Xcode_26.6.app/Contents/Developer
+ xcodebuild -version
+ xcode-select -p
+
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+
+ - name: Install .NET iOS workload
+ run: |
+ dotnet workload install ios
+ dotnet workload list
+
+ - name: Install CocoaPods
+ run: |
+ if ! command -v pod >/dev/null 2>&1; then
+ sudo gem install cocoapods
+ fi
+ pod --version
+
+ - name: Restore tools
+ run: dotnet tool restore
+
+ - name: Pack Google.Maps
+ run: dotnet tool run dotnet-cake -- --target=nuget --names=Google.Maps
+
+ - name: Maps resource manifest check
+ run: python3 scripts/check-maps-resource-manifest.py
+
+ - name: Direct and transitive consumer checks
+ run: >-
+ tools/e2e/check-consumer-shapes.sh
+ --target Maps
+ --package-dir output
+
+ - name: Simulator E2E
+ run: >-
+ tools/e2e/run-google-foundation.sh
+ --target Maps
+ --package-dir output
+ --configuration Debug
+
+ - name: Upload diagnostics
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: google-foundation-maps-diagnostics
path: tests/E2E/Google.Foundation/artifacts/
if-no-files-found: ignore
diff --git a/.github/workflows/maps-resource-integrity.yml b/.github/workflows/maps-resource-integrity.yml
deleted file mode 100644
index 6621cabc..00000000
--- a/.github/workflows/maps-resource-integrity.yml
+++ /dev/null
@@ -1,74 +0,0 @@
-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
deleted file mode 100755
index 3bb9c383..00000000
--- a/scripts/check-maps-consumers.sh
+++ /dev/null
@@ -1,396 +0,0 @@
-#!/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
index e993efdf..90954521 100755
--- a/scripts/check-maps-resource-manifest.py
+++ b/scripts/check-maps-resource-manifest.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Validate Google Maps BundleResource declarations against the pinned SDK archive."""
+"""Validate Google Maps resources against the pinned SDK archive."""
from __future__ import annotations
@@ -20,11 +20,12 @@
EXPECTED_ARCHIVE_SHA256 = (
"81bbd92c2d627087ae222ae955e5f746590812d7389b9d800add15e4004b6431"
)
+ARCHIVE_URL = "https://dl.google.com/dl/cpdc/33a7ac549361ab23/GoogleMaps-9.2.0.tar.gz"
+ARCHIVE_RESOURCE_ROOT = "Maps/Resources/GoogleMapsResources/GoogleMaps.bundle"
+EXPECTED_RESOURCE_FILE_COUNT = 190
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:
@@ -42,7 +43,16 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument(
"--archive",
type=Path,
- help="Use an existing Google Maps .tar.gz instead of downloading the declared URL",
+ help="Use an existing Google Maps .tar.gz instead of downloading the pinned URL",
+ )
+ parser.add_argument(
+ "--copy-bundle-to",
+ type=Path,
+ metavar="DIRECTORY",
+ help=(
+ "After all checks pass, copy the verified bundle to "
+ "DIRECTORY/GoogleMaps.bundle; the destination must not already exist"
+ ),
)
return parser.parse_args()
@@ -51,118 +61,31 @@ 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):
+ if (
+ not normalized
+ or normalized.startswith("/")
+ or path.as_posix() != normalized
+ 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]]:
+def parse_targets(targets_path: Path) -> tuple[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"
- )
+ bundle_resources = elements(root, "BundleResource")
if not bundle_resources:
- errors.append(f"Target {RESTORE_TARGET} declares no BundleResource items")
+ errors.append("Maps.targets declares no BundleResource items")
for index, resource in enumerate(bundle_resources, start=1):
include = resource.get("Include", "")
@@ -204,7 +127,7 @@ def parse_targets(targets_path: Path) -> tuple[str, str, list[str], list[str], l
f"expected {expected_logical_name!r}"
)
- return archive_url, archive_resource_root, includes, logical_names, errors
+ return includes, logical_names, errors
def download_archive(url: str, destination: Path) -> None:
@@ -277,20 +200,94 @@ def compare_sets(actual: set[str], expected: set[str], actual_label: str) -> lis
return errors
+def copy_verified_bundle(
+ archive_path: Path,
+ resource_root: str,
+ relative_files: list[str],
+ destination_parent: Path,
+) -> Path:
+ """Copy regular bundle files from a verified archive without trusting tar paths."""
+
+ destination_parent = destination_parent.expanduser().resolve()
+ if destination_parent.exists() and not destination_parent.is_dir():
+ raise RuntimeError(f"bundle destination parent is not a directory: {destination_parent}")
+ destination_parent.mkdir(parents=True, exist_ok=True)
+
+ destination_bundle = destination_parent / LOGICAL_ROOT
+ if destination_bundle.exists() or destination_bundle.is_symlink():
+ raise RuntimeError(f"bundle destination already exists: {destination_bundle}")
+
+ normalized_root = resource_root.strip("/")
+ prefix = f"{normalized_root}/"
+ expected_files = sorted(relative_files)
+
+ try:
+ with tempfile.TemporaryDirectory(
+ prefix=".googlemaps-bundle-", dir=destination_parent
+ ) as staging_directory, tarfile.open(archive_path, "r:gz") as archive:
+ staging_bundle = Path(staging_directory) / LOGICAL_ROOT
+
+ for member in archive.getmembers():
+ member_name = member.name
+ while member_name.startswith("./"):
+ member_name = member_name[2:]
+ if not member_name.startswith(prefix):
+ continue
+
+ relative_name = member_name[len(prefix) :]
+ if not relative_name:
+ continue
+ if member.isdir():
+ relative_name = relative_name.rstrip("/")
+ relative_path = PurePosixPath(relative_name)
+ if (
+ relative_path.is_absolute()
+ or relative_path.as_posix() != relative_name
+ or any(part in ("", ".", "..") for part in relative_path.parts)
+ ):
+ raise RuntimeError(f"unsafe archive resource path: {member.name!r}")
+
+ output_path = staging_bundle.joinpath(*relative_path.parts)
+ if member.isdir():
+ output_path.mkdir(parents=True, exist_ok=True)
+ continue
+ if not member.isfile():
+ raise RuntimeError(f"archive resource is not a regular file: {member.name}")
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ source = archive.extractfile(member)
+ if source is None:
+ raise RuntimeError(f"could not read archive resource: {member.name}")
+ with source, output_path.open("xb") as destination:
+ shutil.copyfileobj(source, destination)
+
+ copied_files = sorted(
+ path.relative_to(staging_bundle).as_posix()
+ for path in staging_bundle.rglob("*")
+ if path.is_file()
+ )
+ if copied_files != expected_files:
+ raise RuntimeError("copied bundle file set differs from the verified archive manifest")
+
+ staging_bundle.rename(destination_bundle)
+ except (OSError, tarfile.TarError) as exc:
+ raise RuntimeError(f"could not copy verified Google Maps resources: {exc}") from exc
+
+ return destination_bundle
+
+
def main() -> int:
args = parse_arguments()
targets_path = args.targets.resolve()
try:
- archive_url, resource_root, includes, logical_names, errors = parse_targets(targets_path)
+ 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)
+ download_archive(ARCHIVE_URL, archive_path)
actual_sha256 = sha256(archive_path)
if actual_sha256 != EXPECTED_ARCHIVE_SHA256:
@@ -298,40 +295,73 @@ def main() -> int:
f"archive SHA-256 is {actual_sha256}, expected {EXPECTED_ARCHIVE_SHA256}"
)
- archive_files, archive_errors = archive_resource_files(archive_path, resource_root)
+ archive_files, archive_errors = archive_resource_files(
+ archive_path, ARCHIVE_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"))
+ 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")
- )
+ 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 len(includes) != EXPECTED_RESOURCE_FILE_COUNT:
+ errors.append(
+ f"expected {EXPECTED_RESOURCE_FILE_COUNT} BundleResource declarations, "
+ f"found {len(includes)}"
+ )
+ if len(include_set) != EXPECTED_RESOURCE_FILE_COUNT:
+ errors.append(
+ f"expected {EXPECTED_RESOURCE_FILE_COUNT} unique target paths, "
+ f"found {len(include_set)}"
+ )
+ if len(archive_files) != EXPECTED_RESOURCE_FILE_COUNT:
+ errors.append(
+ f"expected {EXPECTED_RESOURCE_FILE_COUNT} archive files, "
+ f"found {len(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(f"Targets: {targets_path}")
+ print(f"Archive URL: {ARCHIVE_URL}")
+ print(f"Archive resource root: {ARCHIVE_RESOURCE_ROOT}")
+ 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
+
+ copied_bundle = None
+ if args.copy_bundle_to:
+ copied_bundle = copy_verified_bundle(
+ archive_path,
+ ARCHIVE_RESOURCE_ROOT,
+ archive_files,
+ args.copy_bundle_to,
+ )
- print("Maps resource manifest check passed.")
- return 0
+ print("Maps resource manifest check passed.")
+ if copied_bundle:
+ print(f"Verified bundle: {copied_bundle}")
+ return 0
+ except (OSError, RuntimeError) as exc:
+ print(f"Maps resource manifest check failed: {exc}", file=sys.stderr)
+ return 1
if __name__ == "__main__":
diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj
index 69cef9aa..6222f123 100644
--- a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj
+++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleFoundationE2E.csproj
@@ -16,13 +16,11 @@
false
-
+
Places
7.4.0.3
+ 9.2.0.8
@@ -41,10 +39,18 @@
$(DefineConstants);ENABLE_TARGET_PLACES
+
+ $(DefineConstants);ENABLE_TARGET_MAPS
+
+
+
+
+
+
<_Parameter1>GoogleE2ETarget
@@ -58,5 +64,9 @@
<_Parameter1>TargetPackageVersion
<_Parameter2>$(PlacesPackageVersion)
+
+ <_Parameter1>TargetPackageVersion
+ <_Parameter2>$(MapsPackageVersion)
+
diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs
index 8b0d367e..11356879 100644
--- a/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs
+++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/GoogleSelfTestRunner.cs
@@ -43,6 +43,11 @@ public static async Task RunAsync(StatusViewController statusViewController)
case "Places":
await PlacesCases.RunAsync(result, statusViewController, ExecuteCaseAsync);
break;
+#endif
+#if ENABLE_TARGET_MAPS
+ case "Maps":
+ await MapsCases.RunAsync(result, statusViewController, ExecuteCaseAsync);
+ break;
#endif
default:
throw new NotSupportedException(
diff --git a/tests/E2E/Google.Foundation/GoogleFoundationE2E/MapsCases.cs b/tests/E2E/Google.Foundation/GoogleFoundationE2E/MapsCases.cs
new file mode 100644
index 00000000..af65fa7f
--- /dev/null
+++ b/tests/E2E/Google.Foundation/GoogleFoundationE2E/MapsCases.cs
@@ -0,0 +1,167 @@
+#if ENABLE_TARGET_MAPS
+using System.Runtime.InteropServices;
+using CoreLocation;
+using Foundation;
+using ObjCRuntime;
+using MapsMapView = Google.Maps.MapView;
+
+namespace GoogleFoundationE2E;
+
+///
+/// Delivery-focused checks for AdamE.Google.iOS.Maps.
+///
+/// These checks require neither an API key nor network access. They prove that the managed binding,
+/// native symbols, Objective-C classes, and packaged resources reached the app without initializing
+/// the Maps service or contacting its backend.
+///
+public static class MapsCases
+{
+ const string BundleName = "GoogleMaps";
+ const int ExpectedBundleFileCount = 190;
+
+ public static async Task RunAsync(
+ GoogleE2ERunResult result,
+ StatusViewController status,
+ Func>, Task> execute)
+ {
+ await execute(result, status, "maps-managed-binding-loads", VerifyManagedBindingLoadsAsync);
+ await execute(result, status, "maps-pinvoke-linkage", VerifyPInvokeLinkageAsync);
+ await execute(result, status, "maps-objc-class-lookup", VerifyObjCClassLookupAsync);
+ await execute(result, status, "maps-resource-bundle-present", VerifyResourceBundlePresentAsync);
+ await execute(result, status, "maps-resource-bundle-contents", VerifyResourceBundleContentsAsync);
+ }
+
+ static Task VerifyManagedBindingLoadsAsync()
+ {
+ var type = typeof(MapsMapView);
+ var name = type.Assembly.GetName();
+
+ return Task.FromResult($"{type.FullName} loaded from {name.Name} {name.Version}.");
+ }
+
+ [DllImport("__Internal", EntryPoint = "GMSGeometryDistance")]
+ static extern double GMSGeometryDistance(
+ CLLocationCoordinate2D fromCoordinate,
+ CLLocationCoordinate2D toCoordinate);
+
+ static Task VerifyPInvokeLinkageAsync()
+ {
+ var fromCoordinate = new CLLocationCoordinate2D(37.7749, -122.4194);
+ var toCoordinate = new CLLocationCoordinate2D(37.7849, -122.4094);
+
+ try
+ {
+ var distance = GMSGeometryDistance(fromCoordinate, toCoordinate);
+ if (!double.IsFinite(distance) || distance <= 0)
+ {
+ throw new InvalidOperationException(
+ $"GMSGeometryDistance resolved but returned invalid distance {distance}.");
+ }
+
+ return Task.FromResult($"GMSGeometryDistance resolved and returned {distance:F2} meters.");
+ }
+ catch (EntryPointNotFoundException ex)
+ {
+ throw new InvalidOperationException(
+ "GMSGeometryDistance did not resolve, so the GoogleMaps native library was not " +
+ "linked into the app. This is a package delivery failure.", ex);
+ }
+ catch (DllNotFoundException ex)
+ {
+ throw new InvalidOperationException(
+ "The __Internal native library was not found, so the GoogleMaps native framework " +
+ "was not linked into the app. This is a package delivery failure.", ex);
+ }
+ }
+
+ static Task VerifyObjCClassLookupAsync()
+ {
+ string[] expectedClasses = ["GMSMapView", "GMSCameraPosition", "GMSMarker"];
+
+ var missing = expectedClasses
+ .Where(name => (IntPtr)Class.GetHandle(name) == IntPtr.Zero)
+ .ToArray();
+
+ if (missing.Length > 0)
+ {
+ throw new InvalidOperationException(
+ "Objective-C classes missing from the loaded image: " + string.Join(", ", missing) +
+ ". ForceLoad or the -ObjC linker flag did not survive packaging.");
+ }
+
+ return Task.FromResult(
+ $"Resolved {expectedClasses.Length} Objective-C classes: {string.Join(", ", expectedClasses)}.");
+ }
+
+ static Task VerifyResourceBundlePresentAsync()
+ {
+ var appBundlePath = NSBundle.MainBundle.BundlePath;
+ var expectedBundlePath = Path.Combine(appBundlePath, BundleName + ".bundle");
+ var matches = Directory
+ .GetDirectories(appBundlePath, BundleName + ".bundle", SearchOption.AllDirectories)
+ .Select(Path.GetFullPath)
+ .ToArray();
+
+ if (matches.Length != 1)
+ {
+ throw new InvalidOperationException(
+ $"Expected exactly one {BundleName}.bundle in the app, found {matches.Length}: " +
+ string.Join(", ", matches));
+ }
+
+ if (!string.Equals(matches[0], Path.GetFullPath(expectedBundlePath), StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"{BundleName}.bundle was delivered at {matches[0]}, expected the app root at " +
+ $"{expectedBundlePath}.");
+ }
+
+ return Task.FromResult($"Exactly one root {BundleName}.bundle resolved at {matches[0]}.");
+ }
+
+ static Task VerifyResourceBundleContentsAsync()
+ {
+ var bundlePath = Path.Combine(NSBundle.MainBundle.BundlePath, BundleName + ".bundle");
+ if (!Directory.Exists(bundlePath))
+ {
+ throw new InvalidOperationException($"{BundleName}.bundle was not found at the app root.");
+ }
+
+ string[] expectedFiles =
+ [
+ "Info.plist",
+ "PrivacyInfo.xcprivacy",
+ "Assets.car",
+ Path.Combine("GMSCacheStorage.momd", "Storage.mom"),
+ Path.Combine("GMSCoreResources.bundle", "en.lproj", "GMSCore.strings"),
+ ];
+
+ var missingOrEmpty = expectedFiles
+ .Where(relative =>
+ {
+ var path = Path.Combine(bundlePath, relative);
+ return !File.Exists(path) || new FileInfo(path).Length == 0;
+ })
+ .ToArray();
+
+ if (missingOrEmpty.Length > 0)
+ {
+ throw new InvalidOperationException(
+ $"{BundleName}.bundle is missing expected non-empty files: " +
+ string.Join(", ", missingOrEmpty));
+ }
+
+ var fileCount = Directory.GetFiles(bundlePath, "*", SearchOption.AllDirectories).Length;
+ if (fileCount != ExpectedBundleFileCount)
+ {
+ throw new InvalidOperationException(
+ $"{BundleName}.bundle contains {fileCount} files; expected the " +
+ $"{ExpectedBundleFileCount}-file Maps 9.2.0 delivery baseline.");
+ }
+
+ return Task.FromResult(
+ $"{BundleName}.bundle contains {fileCount} files including all " +
+ $"{expectedFiles.Length} spot-checked entries.");
+ }
+}
+#endif
diff --git a/tests/E2E/Google.Foundation/README.md b/tests/E2E/Google.Foundation/README.md
index 90947090..bbcea086 100644
--- a/tests/E2E/Google.Foundation/README.md
+++ b/tests/E2E/Google.Foundation/README.md
@@ -10,7 +10,8 @@ binding-layer failures such as `EntryPointNotFoundException`, `DllNotFoundExcept
## Current scope and baseline
-The sole runtime adapter is `AdamE.Google.iOS.Places` 7.4.0.3.
+Runtime adapters currently cover `AdamE.Google.iOS.Places` 7.4.0.3 and
+`AdamE.Google.iOS.Maps` 9.2.0.8.
The behavioral expectations were established against the 7.4.0.2 pre-migration package, which used
`Xamarin.Build.Download` to fetch the Google Places SDK during the consumer build. The harness does
@@ -25,13 +26,24 @@ The Places adapter verifies:
- The bundle contains the baseline's 59 files, including representative data, localized string, and
image files.
-The companion `check-package-structure.sh` and `check-offline-build.sh` scripts inspect the package
-and prove the consumer build no longer downloads native content. The harness does not compare stored
-baselines, exercise a physical device, or test the Places backend.
+The Maps expectations are established against the 9.2.0.8 package while it still uses
+`Xamarin.Build.Download`. Like the Places adapter, its checks are independent of how the SDK arrives.
+The Maps adapter verifies:
+
+- `Google.Maps.MapView` loads from the restored binding assembly.
+- `GMSGeometryDistance` resolves and returns a finite, positive distance.
+- `GMSMapView`, `GMSCameraPosition`, and `GMSMarker` are present in the Objective-C runtime.
+- Exactly one `GoogleMaps.bundle` is present at the app root.
+- The bundle contains the upstream SDK's 190 files, including representative privacy, asset, model,
+ and nested resource files.
+
+For self-contained targets, the companion `check-package-structure.sh` and `check-offline-build.sh`
+scripts inspect the package and prove the consumer build no longer downloads native content. The
+harness does not compare stored baselines, exercise a physical device, or test Google backends.
## Run it
-Pack Places, then launch the runtime checks on an available iPhone simulator:
+Pack a target, then launch its runtime checks on an available iPhone simulator:
```sh
dotnet tool restore
@@ -39,8 +51,10 @@ dotnet tool run dotnet-cake -- --target=nuget --names=Google.Places
tools/e2e/run-google-foundation.sh --target Places --package-dir output
```
-If the package directory contains exactly one `AdamE.Google.iOS.Places.*.nupkg`, the runner derives
-and records its version. Zero or multiple matching packages are rejected so a run cannot silently
+For the Maps baseline, replace `Google.Places` and `Places` with `Google.Maps` and `Maps`.
+
+If the package directory contains exactly one matching nonsymbol package, the runner derives and
+records its version. Zero or multiple matching packages are rejected so a run cannot silently
exercise the wrong artifact.
When the directory contains multiple versions, select one explicitly:
diff --git a/tools/e2e/check-consumer-shapes.sh b/tools/e2e/check-consumer-shapes.sh
index 77c3ac5d..1c7a7a14 100755
--- a/tools/e2e/check-consumer-shapes.sh
+++ b/tools/e2e/check-consumer-shapes.sh
@@ -3,7 +3,7 @@ set -euo pipefail
usage() {
cat <<'EOF'
-Usage: tools/e2e/check-consumer-shapes.sh --target [options]
+Usage: tools/e2e/check-consumer-shapes.sh --target [options]
--package-dir Local NuGet feed (default: output)
--package-version Exact package version (required when the feed contains multiple)
@@ -21,6 +21,7 @@ A binding package's .targets historically gated its items on OutputType != 'Libr
library shape is where transitive delivery quietly breaks.
Targets differ in how the native payload arrives, and the assertions follow that:
+ Maps static xcframework -- verified upstream bundle at app root, symbol linked into app
Places static xcframework -- bundle unpacked to the app root, symbol linked into the app binary
SignIn dynamic xcframework -- .framework copied to App.app/Frameworks with its bundle inside
EOF
@@ -46,16 +47,38 @@ done
[[ "$package_dir" != /* ]] && package_dir="$repo_root/$package_dir"
case "$target" in
+ Maps)
+ package_id="AdamE.Google.iOS.Maps"
+ # GoogleMaps.xcframework is static. Its resource bundle is distributed beside the framework in
+ # the upstream archive and must arrive exactly once at the app root.
+ delivery="static"
+ resource_bundle="GoogleMaps.bundle"
+ managed_probe_expression='Google.Maps.GeometryUtils.Distance(new CoreLocation.CLLocationCoordinate2D(0, 0), new CoreLocation.CLLocationCoordinate2D(1, 1))'
+ lib_project="MapsLib"
+ probe_symbol="GMSGeometryDistance"
+ probe_symbol_exact="_GMSGeometryDistance"
+ expected_resource_file_count=190
+ compare_expected_bundle="true"
+ forbidden_dynamic_framework="GoogleMaps.framework"
+ source_targets="$repo_root/source/Google/Maps/Maps.targets"
+ # Calling the managed binding above creates the native reference; no extra DllImport is needed.
+ native_probe_decl=""
+ native_probe_call=""
+ ;;
Places)
package_id="AdamE.Google.iOS.Places"
# GooglePlaces.xcframework is a static framework: the SDK links it into the app binary and
# does not copy any .framework directory, so the package has to place the bundle at the app root.
delivery="static"
resource_bundle="GooglePlaces.bundle"
- managed_probe_type="Google.Places.AutocompleteFilter"
+ managed_probe_expression="typeof(Google.Places.AutocompleteFilter).FullName!"
lib_project="PlacesLib"
probe_symbol="GMSPlaceRectangularLocationOption"
+ probe_symbol_exact=""
expected_resource_file_count=59
+ compare_expected_bundle="false"
+ forbidden_dynamic_framework=""
+ source_targets=""
# A trivial app that never reaches a native entry point lets the linker drop the static
# library, which is correct behaviour and not a delivery failure. Call into the native SDK so
# the symbol assertion below actually measures whether the package delivered it.
@@ -73,7 +96,7 @@ case "$target" in
framework_dir="GoogleSignIn.framework"
framework_binary="GoogleSignIn"
resource_bundle="GoogleSignIn.bundle"
- managed_probe_type="Google.SignIn.SignIn"
+ managed_probe_expression="typeof(Google.SignIn.SignIn).FullName!"
lib_project="SignInLib"
# Touching the managed type is enough here: the framework is a native reference, so the SDK
# copies it whenever the binding assembly survives the linker. There is no DllImport probe
@@ -81,6 +104,11 @@ case "$target" in
native_probe_decl=""
native_probe_call=""
expected_resource_file_count=""
+ probe_symbol=""
+ probe_symbol_exact=""
+ compare_expected_bundle="false"
+ forbidden_dynamic_framework=""
+ source_targets=""
;;
*) echo "Unknown target: $target" >&2; exit 1 ;;
esac
@@ -118,10 +146,8 @@ else
fi
echo "Testing $package_id $package_version from $nupkg"
-msbuild_args=()
-[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false")
-
failures=0
+completed="false"
pass() { print -r -- " PASS $1"; }
fail() { print -r -- " FAIL $1" >&2; failures=$((failures + 1)); }
@@ -132,22 +158,75 @@ work="$(cd "$(mktemp -d)" && pwd -P)"
# Keep the selected local package isolated from any same-version copy in the user's global cache.
# This makes the check prove the nupkg named above rather than whichever copy NuGet restored first.
export NUGET_PACKAGES="$work/packages"
-mkdir -p "$NUGET_PACKAGES"
+# XBD-delivered packages use this fresh directory; embedded packages safely ignore the property.
+# The trailing slash is required by legacy targets that concatenate the property with a folder name.
+xbd_dir="$work/xbd/"
+mkdir -p "$NUGET_PACKAGES" "$xbd_dir"
+msbuild_args=("-p:XamarinBuildDownloadDir=$xbd_dir")
+[[ "$allow_xcode_mismatch" == "true" ]] && msbuild_args+=("-p:ValidateXcodeVersion=false")
# The scaffold lives outside the repository tree, so give its projects the same pinned SDK and
# workload set that packed the selected artifact. Without this, a fresh CI host can install the
# pinned iOS workload successfully and then resolve the consumer against a different workload set.
cp "$repo_root/global.json" "$work/global.json"
+diagnostics_dir="$repo_root/tests/E2E/Google.Foundation/artifacts/consumer-shapes-$target"
# Keep the scaffold when something fails -- these are throwaway projects, but a failure is not
# diagnosable without the build logs and the produced .app.
cleanup() {
- if (( failures > 0 )); then
+ local exit_status=$?
+
+ if [[ "$completed" != "true" ]]; then
+ mkdir -p "$diagnostics_dir"
+ cp "$work"/*.log(N) "$work"/*.diff(N) "$work"/*.txt(N) "$diagnostics_dir/" 2>/dev/null || true
+ print -r -- "Diagnostics copied to $diagnostics_dir" >&2
print -r -- "Scaffold kept for inspection: $work" >&2
else
rm -rf "$work"
fi
+
+ return "$exit_status"
}
trap cleanup EXIT
+expected_bundle=""
+if [[ "$compare_expected_bundle" == "true" ]]; then
+ expected_bundle_parent="$work/verified-upstream"
+ if python3 "$repo_root/scripts/check-maps-resource-manifest.py" \
+ --copy-bundle-to "$expected_bundle_parent" > "$work/maps-resource-manifest.log" 2>&1; then
+ expected_bundle="$expected_bundle_parent/$resource_bundle"
+ if [[ -d "$expected_bundle" ]]; then
+ pass "verified upstream $resource_bundle materialized"
+ else
+ fail "verified upstream checker did not materialize $expected_bundle"
+ exit 1
+ fi
+ else
+ fail "could not validate and materialize the upstream $resource_bundle"
+ tail -25 "$work/maps-resource-manifest.log" >&2
+ exit 1
+ fi
+fi
+
+if [[ -n "$source_targets" ]]; then
+ 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 diff -u "$source_targets" "$packaged_targets" > "$work/$folder-targets.diff"; then
+ pass "$folder/$package_id.targets matches source"
+ else
+ fail "$folder/$package_id.targets differs from $source_targets (see $work/$folder-targets.diff)"
+ fi
+ else
+ fail "$folder/$package_id.targets is missing from the selected package"
+ fi
+ done
+
+ if (( failures > 0 )); then
+ exit 1
+ fi
+fi
+
cat > "$work/NuGet.config" <
@@ -225,6 +304,24 @@ EOF
EOF
}
+write_file_manifest() {
+ local root="$1" destination="$2"
+ (cd "$root" && find . -type f -print | sed 's|^\./||' | LC_ALL=C sort) > "$destination"
+}
+
+assert_restored_package() {
+ local shape="$1" package_id_lower="${package_id:l}"
+ local restored_nupkg="$NUGET_PACKAGES/$package_id_lower/$package_version/$package_id_lower.$package_version.nupkg"
+
+ if [[ ! -f "$restored_nupkg" ]]; then
+ fail "$shape: restored package is missing at $restored_nupkg"
+ elif cmp -s "$nupkg" "$restored_nupkg"; then
+ pass "$shape: restore consumed the selected local package"
+ else
+ fail "$shape: restored package differs from $nupkg"
+ fi
+}
+
# Reports a check that is expected to fail today because of a defect that predates this harness.
# Visible in the output, but does not fail the run -- otherwise the only way to keep CI green would
# be to delete the check, and the gap would stop being visible at all.
@@ -296,9 +393,56 @@ assert_app() {
return
fi
- if [[ -d "$app_path/$resource_bundle" ]]; then
+ local app_resource_bundle="$app_path/$resource_bundle"
+ if [[ "$compare_expected_bundle" == "true" ]]; then
+ local bundle_matches bundle_count actual_manifest expected_manifest content_mismatches relative_file
+ bundle_matches="$(find "$app_path" -type d -name "$resource_bundle" -print)"
+ bundle_count="$(print -r -- "$bundle_matches" | sed '/^$/d' | wc -l | tr -d ' ')"
+ if [[ "$bundle_count" != "1" ]]; then
+ $report "$shape: expected exactly one $resource_bundle, found $bundle_count"
+ return
+ fi
+
+ app_resource_bundle="$(print -r -- "$bundle_matches" | sed -n '1p')"
+ if [[ "$app_resource_bundle" == "$app_path/$resource_bundle" ]]; then
+ pass "$shape: exactly one root $resource_bundle is present"
+ else
+ $report "$shape: $resource_bundle is not at the app root ($app_resource_bundle)"
+ fi
+
+ expected_manifest="$work/expected-bundle-files.txt"
+ actual_manifest="$work/$shape-bundle-files.txt"
+ [[ -s "$expected_manifest" ]] || write_file_manifest "$expected_bundle" "$expected_manifest"
+ write_file_manifest "$app_resource_bundle" "$actual_manifest"
+
+ if diff -u "$expected_manifest" "$actual_manifest" > "$work/$shape-bundle.diff"; then
+ local actual_count
+ actual_count="$(wc -l < "$actual_manifest" | tr -d ' ')"
+ if [[ -n "$expected_resource_file_count" && "$actual_count" -ne "$expected_resource_file_count" ]]; then
+ $report "$shape: $resource_bundle contains $actual_count files; expected $expected_resource_file_count"
+ else
+ pass "$shape: bundle file set matches verified upstream ($actual_count files)"
+ fi
+
+ content_mismatches="$work/$shape-content-mismatches.txt"
+ : > "$content_mismatches"
+ while IFS= read -r relative_file; do
+ if ! cmp -s "$expected_bundle/$relative_file" "$app_resource_bundle/$relative_file"; then
+ print -r -- "$relative_file" >> "$content_mismatches"
+ fi
+ done < "$expected_manifest"
+
+ if [[ -s "$content_mismatches" ]]; then
+ $report "$shape: bundle contents differ from verified upstream (see $content_mismatches)"
+ else
+ pass "$shape: bundle contents are byte-for-byte identical to verified upstream"
+ fi
+ else
+ $report "$shape: bundle file set differs from verified upstream (see $work/$shape-bundle.diff)"
+ fi
+ elif [[ -d "$app_resource_bundle" ]]; then
local count
- count="$(find "$app_path/$resource_bundle" -type f | wc -l | tr -d ' ')"
+ count="$(find "$app_resource_bundle" -type f | wc -l | tr -d ' ')"
if [[ -n "$expected_resource_file_count" && "$count" -ne "$expected_resource_file_count" ]]; then
$report "$shape: $resource_bundle contains $count files; expected $expected_resource_file_count"
else
@@ -311,20 +455,39 @@ assert_app() {
# Do not use `grep -q` here: it exits on the first match, nm takes SIGPIPE, and with pipefail the
# successful match is reported as a failed pipeline. grep -c consumes all input, so no SIGPIPE.
local symbol_count
- symbol_count="$(nm "$binary" 2>/dev/null | grep -c "$probe_symbol" || true)"
+ if [[ -n "$probe_symbol_exact" ]]; then
+ symbol_count="$(nm -U "$binary" 2>/dev/null \
+ | awk -v expected="$probe_symbol_exact" '$NF == expected { count++ } END { print count + 0 }')"
+ else
+ symbol_count="$(nm "$binary" 2>/dev/null | grep -c "$probe_symbol" || true)"
+ fi
if [[ "${symbol_count:-0}" -gt 0 ]]; then
pass "$shape: $probe_symbol linked into the app binary ($symbol_count symbol(s))"
else
$report "$shape: $probe_symbol not found in $exe_name"
fi
+
+ if [[ -n "$forbidden_dynamic_framework" ]]; then
+ local framework_count
+ framework_count=0
+ if [[ -d "$app_path/Frameworks" ]]; then
+ framework_count="$(find "$app_path/Frameworks" -type d -name "$forbidden_dynamic_framework" \
+ -print | wc -l | tr -d ' ')"
+ fi
+ if [[ "$framework_count" == "0" ]]; then
+ pass "$shape: no dynamic $forbidden_dynamic_framework copy is present"
+ else
+ $report "$shape: static framework was unexpectedly copied to App.app/Frameworks/$forbidden_dynamic_framework"
+ fi
+ fi
}
# ------------------------------------------------------------------ shape: direct
echo
echo "Shape: direct (app -> package)"
direct="$work/direct"
-write_app_sources "$direct" "typeof($managed_probe_type).FullName!"
+write_app_sources "$direct" "$managed_probe_expression"
cat > "$direct/DirectApp.csproj" <
$app_props
@@ -338,6 +501,9 @@ cat > "$direct/DirectApp.csproj" < "$work/direct.log" 2>&1; then
+ if [[ "$target" == "Maps" ]]; then
+ assert_restored_package "direct"
+ fi
assert_app "direct" "$(find "$direct/bin" -name "DirectApp.app" -print -quit)"
else
fail "direct: build failed (see $work/direct.log)"
@@ -368,7 +534,7 @@ namespace ShapeProbe.Lib;
public static class Probe
{
- public static string Describe() => typeof($managed_probe_type).FullName!;
+ public static object Describe() => $managed_probe_expression;
}
EOF
@@ -389,9 +555,12 @@ EOF
# integration could not reach an app consuming it through a ProjectReference. That was an artefact
# of the scaffold rather than a packaging defect: mktemp -d handed back a symlinked /var path, NuGet
# dropped the ProjectReference edge, and the app silently never referenced the package at all. With
-# the scaffold path canonicalised above, Places and SignIn both deliver correctly through a class
+# the scaffold path canonicalised above, Maps, Places and SignIn all deliver correctly through a class
# library, so this is a strict check.
if dotnet build "$lib_root/App/LibraryApp.csproj" -c Debug "${msbuild_args[@]}" > "$work/library.log" 2>&1; then
+ if [[ "$target" == "Maps" ]]; then
+ assert_restored_package "library"
+ fi
assert_app "library" "$(find "$lib_root/App/bin" -name "LibraryApp.app" -print -quit)"
else
fail "library: build failed (see $work/library.log)"
@@ -401,12 +570,10 @@ fi
echo
if (( failures > 0 )); then
echo "$failures consumer-shape check(s) failed." >&2
- diagnostics_dir="$repo_root/tests/E2E/Google.Foundation/artifacts/consumer-shapes-$target"
- mkdir -p "$diagnostics_dir"
- cp "$work"/*.log "$diagnostics_dir/" 2>/dev/null || true
exit 1
fi
+completed="true"
if (( known_gaps > 0 )); then
echo "Consumer-shape checks passed, with $known_gaps known gap(s) reported above."
else
diff --git a/tools/e2e/run-google-foundation.sh b/tools/e2e/run-google-foundation.sh
index c226b657..5b816d0b 100755
--- a/tools/e2e/run-google-foundation.sh
+++ b/tools/e2e/run-google-foundation.sh
@@ -62,9 +62,13 @@ mkdir -p "$artifacts_dir"
rm -rf "$packages_cache_dir"
mkdir -p "$packages_cache_dir"
-# Map the runtime adapter to its package and version property. Places is intentionally the only
-# implemented adapter; adding another target requires an explicit package-specific runtime case.
+# Map each runtime adapter to its package and version property. Every supported target must have
+# explicit package-specific runtime cases in the harness.
case "$target" in
+ Maps)
+ package_id="AdamE.Google.iOS.Maps"
+ package_version_property="MapsPackageVersion"
+ ;;
Places)
package_id="AdamE.Google.iOS.Places"
package_version_property="PlacesPackageVersion"