[CmdletBinding()] param( [string]$ApkPath, [ValidateSet('custom-base', 'dcloud-test', 'release')] [string]$BuildKind = 'custom-base', [string]$HBuilderHome, [string]$ExpectedSignerSha256, [string]$ExpectedTermsUrl, [string]$ExpectedPrivacyUrl ) $ErrorActionPreference = 'Stop' $scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path $projectRoot = Split-Path -Parent $scriptDirectory if ([string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) { $ExpectedTermsUrl = $env:VITE_APP_TERMS_URL } if ([string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl)) { $ExpectedPrivacyUrl = $env:VITE_APP_PRIVACY_URL } if ( [string]::IsNullOrWhiteSpace($ExpectedTermsUrl) -xor [string]::IsNullOrWhiteSpace($ExpectedPrivacyUrl) ) { throw 'ExpectedTermsUrl and ExpectedPrivacyUrl must be provided together.' } if ([string]::IsNullOrWhiteSpace($ApkPath)) { $ApkPath = Join-Path $projectRoot 'dist\debug\android_debug.apk' } $resolvedApkPath = (Resolve-Path -LiteralPath $ApkPath).Path if ([IO.Path]::GetExtension($resolvedApkPath) -ne '.apk') { throw "Expected an .apk file: $resolvedApkPath" } if ((Get-Item -LiteralPath $resolvedApkPath).Length -lt 1MB) { throw "APK is unexpectedly small: $resolvedApkPath" } $sourceManifestPath = Join-Path $projectRoot 'src\manifest.json' $sourceManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $sourceManifestPath | ConvertFrom-Json $sourceAndroid = $sourceManifest.'app-plus'.distribute.android $candidateHomes = @() if (-not [string]::IsNullOrWhiteSpace($HBuilderHome)) { $candidateHomes += $HBuilderHome } if (-not [string]::IsNullOrWhiteSpace($env:HBUILDERX_HOME)) { $candidateHomes += $env:HBUILDERX_HOME } $candidateHomes += 'D:\HBuilderX' $resolvedHBuilderHome = $candidateHomes | Where-Object { Test-Path -LiteralPath (Join-Path $_ 'plugins\app-safe-pack\apktool.jar') } | Select-Object -First 1 if ([string]::IsNullOrWhiteSpace($resolvedHBuilderHome)) { throw 'HBuilderX app-safe-pack tools were not found. Pass -HBuilderHome or set HBUILDERX_HOME.' } $java = Get-Command java -ErrorAction Stop $apktoolJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apktool.jar' $apksignerJar = Join-Path $resolvedHBuilderHome 'plugins\app-safe-pack\apksigner.jar' if (-not (Test-Path -LiteralPath $apksignerJar)) { throw "Missing APK signer verifier: $apksignerJar" } $temporaryRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) $decodePath = [IO.Path]::GetFullPath( (Join-Path $temporaryRoot ('bangdao-apk-verify-' + [Guid]::NewGuid().ToString('N'))) ) if (-not $decodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to use a temporary path outside the system temp directory: $decodePath" } try { & $java.Source -jar $apktoolJar d -s -o $decodePath $resolvedApkPath 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { throw "apktool failed to decode $resolvedApkPath" } $decodedManifestPath = Join-Path $decodePath 'AndroidManifest.xml' $apktoolMetadataPath = Join-Path $decodePath 'apktool.yml' [xml]$decodedManifest = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath $decodedManifestText = Get-Content -Raw -Encoding UTF8 -LiteralPath $decodedManifestPath $apktoolMetadata = Get-Content -Raw -Encoding UTF8 -LiteralPath $apktoolMetadataPath $packageName = $decodedManifest.manifest.package $targetSdkMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*targetSdkVersion:\s*(\d+)\s*$') $versionCodeMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionCode:\s*(\d+)\s*$') $versionNameMatch = [regex]::Match($apktoolMetadata, '(?m)^\s*versionName:\s*(\S+)\s*$') if (-not $targetSdkMatch.Success -or -not $versionCodeMatch.Success -or -not $versionNameMatch.Success) { throw 'APK metadata is missing targetSdkVersion, versionCode, or versionName.' } $targetSdkVersion = [int]$targetSdkMatch.Groups[1].Value $versionCode = $versionCodeMatch.Groups[1].Value $versionName = $versionNameMatch.Groups[1].Value.Trim("'`"") if ($packageName -ne $sourceAndroid.packagename) { throw "APK package mismatch: got $packageName, expected $($sourceAndroid.packagename)" } if ($targetSdkVersion -ne [int]$sourceAndroid.targetSdkVersion) { throw "APK targetSdkVersion mismatch: got $targetSdkVersion, expected $($sourceAndroid.targetSdkVersion)" } if ($versionCode -ne [string]$sourceManifest.versionCode) { throw "APK versionCode mismatch: got $versionCode, expected $($sourceManifest.versionCode)" } if ($versionName -ne [string]$sourceManifest.versionName) { throw "APK versionName mismatch: got $versionName, expected $($sourceManifest.versionName)" } if ($BuildKind -eq 'custom-base') { # A DCloud custom base APK contains only the native debug runtime. HBuilderX # syncs the compiled www resources separately when it runs the app on a # device, so both no-prompt and login-link checks use the compiled App files. $compiledPrivacyPath = Join-Path $projectRoot 'dist\build\app\androidPrivacy.json' if (-not (Test-Path -LiteralPath $compiledPrivacyPath)) { throw 'Compiled App resources are missing androidPrivacy.json; run build:app first.' } $privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $compiledPrivacyPath $legalAppServiceFiles = @( @( (Join-Path $projectRoot 'dist\build\app\app-service.js'), (Join-Path $projectRoot 'dist\build\app-plus\app-service.js') ) | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 ) } else { $bundledPrivacyFiles = @( Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') ` -Recurse -File -Filter 'androidPrivacy.json' ) if ($bundledPrivacyFiles.Count -ne 1) { throw "Expected exactly one bundled androidPrivacy.json, found $($bundledPrivacyFiles.Count)." } $privacyJson = Get-Content -Raw -Encoding UTF8 -LiteralPath $bundledPrivacyFiles[0].FullName $legalAppServiceFiles = @( Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') ` -Recurse -File -Filter 'app-service.js' ) } $privacyConfig = $privacyJson | ConvertFrom-Json if ($privacyConfig.prompt -ne 'none') { throw 'Packaged privacy configuration must disable the DCloud native prompt with prompt=none.' } foreach ($templateOnlyField in @( 'title', 'message', 'buttonAccept', 'buttonRefuse', 'hrefLoader', 'second', 'disagreeMode', 'styles' )) { if ($privacyConfig.PSObject.Properties.Name -contains $templateOnlyField) { throw "Packaged prompt=none privacy configuration must not contain $templateOnlyField." } } if (-not [string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) { if ($legalAppServiceFiles.Count -ne 1) { throw "Expected exactly one App service for login legal-link verification, found $($legalAppServiceFiles.Count)." } $legalAppService = Get-Content -Raw -Encoding UTF8 -LiteralPath $legalAppServiceFiles[0] if (-not $legalAppService.Contains($ExpectedTermsUrl)) { throw 'Packaged login flow is missing the expected service agreement URL.' } if (-not $legalAppService.Contains($ExpectedPrivacyUrl)) { throw 'Packaged login flow is missing the expected privacy policy URL.' } if (-not $legalAppService.Contains('请先阅读并勾选同意')) { throw 'Packaged login flow is missing the unchecked-consent failure message.' } } if ($BuildKind -ne 'custom-base') { $bundledAppServiceFiles = @( Get-ChildItem -LiteralPath (Join-Path $decodePath 'assets\apps') ` -Recurse -File -Filter 'app-service.js' ) if ($bundledAppServiceFiles.Count -ne 1) { throw "Expected exactly one bundled app-service.js, found $($bundledAppServiceFiles.Count)." } $bundledAppServiceSha256 = ( Get-FileHash -Algorithm SHA256 -LiteralPath $bundledAppServiceFiles[0].FullName ).Hash $compiledAppServiceCandidates = @( (Join-Path $projectRoot 'dist\build\app\app-service.js'), (Join-Path $projectRoot 'dist\build\app-plus\app-service.js') ) | Where-Object { Test-Path -LiteralPath $_ } if ($compiledAppServiceCandidates.Count -eq 0) { throw 'Compiled App resources are missing app-service.js; run build:app or HBuilderX pack first.' } $matchingCompiledAppService = $compiledAppServiceCandidates | Where-Object { (Get-FileHash -Algorithm SHA256 -LiteralPath $_).Hash -eq $bundledAppServiceSha256 } | Select-Object -First 1 if ([string]::IsNullOrWhiteSpace($matchingCompiledAppService)) { throw 'APK app-service.js does not match the current compiled App resource.' } } $sensitiveFiles = @( Get-ChildItem -LiteralPath $decodePath -Recurse -File | Where-Object { $_.Name -match '^(?:\.env(?:\..*)?|id_rsa)$' -or $_.Extension -match '^\.(?:jks|keystore|p12|pfx|pem)$' } ) if ($sensitiveFiles.Count -gt 0) { throw "APK contains a sensitive file: $($sensitiveFiles[0].Name)" } $sensitiveRules = [ordered]@{ 'private key' = 'BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY' 'cloud access key' = '\b(?:AKIA|ASIA)[A-Z0-9]{16}\b' 'GitHub token' = '\b(?:ghp_|github_pat_)[A-Za-z0-9_]{20,}\b' 'model API key' = '\bsk-[A-Za-z0-9_-]{20,}\b' 'mobile number' = '(?') { throw "$BuildKind APK network security config must trust system certificates only." } if ($decodedNetworkSecurity -match '&1 | Out-String if ($LASTEXITCODE -ne 0 -or $signatureOutput -notmatch 'Verified using v2 scheme \(APK Signature Scheme v2\): true') { throw 'APK signature verification failed or APK Signature Scheme v2 is missing.' } if ($signatureOutput -notmatch 'Number of signers:\s*1') { throw 'APK must have exactly one signer.' } $signerMatch = [regex]::Match($signatureOutput, 'Signer #1 certificate SHA-256 digest:\s*([0-9a-fA-F]+)') if (-not $signerMatch.Success) { throw 'APK signer certificate SHA-256 digest could not be read.' } $signerSha256 = $signerMatch.Groups[1].Value.ToUpperInvariant() if ( $BuildKind -eq 'release' -and $signerSha256 -ne $ExpectedSignerSha256.Replace(':', '').ToUpperInvariant() ) { throw "APK signer mismatch: got $signerSha256" } $apkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedApkPath).Hash Write-Output 'Android APK verification passed.' Write-Output " kind: $BuildKind" Write-Output " package: $packageName" Write-Output " version: $versionName ($versionCode)" Write-Output " targetSdkVersion: $targetSdkVersion" Write-Output " usesCleartextTraffic: $usesCleartextTraffic" Write-Output " signer SHA-256: $signerSha256" Write-Output " APK SHA-256: $apkSha256" Write-Output " path: $resolvedApkPath" } finally { if (Test-Path -LiteralPath $decodePath) { $resolvedDecodePath = [IO.Path]::GetFullPath($decodePath) if ( $resolvedDecodePath.StartsWith($temporaryRoot, [StringComparison]::OrdinalIgnoreCase) -and $resolvedDecodePath -ne $temporaryRoot ) { Remove-Item -LiteralPath $resolvedDecodePath -Recurse -Force } } }