release: prepare August 1 Android RC

This commit is contained in:
key
2026-07-29 00:35:15 +08:00
parent 9ae5f750c8
commit 9ea5d79a62
41 changed files with 2322 additions and 71 deletions
+116
View File
@@ -0,0 +1,116 @@
import { isIP } from 'node:net';
const PLACEHOLDER_PATTERN = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i;
const RESERVED_HOSTNAME_PATTERN = /(?:^|\.)(?:example|invalid|test)$/i;
const isPrivateIpv4 = (hostname) => {
const parts = hostname.split('.').map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
return false;
}
return parts[0] === 0
|| parts[0] === 10
|| parts[0] === 127
|| (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127)
|| (parts[0] === 169 && parts[1] === 254)
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|| (parts[0] === 192 && parts[1] === 168)
|| (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2))
|| (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19))
|| (parts[0] === 198 && parts[1] === 51 && parts[2] === 100)
|| (parts[0] === 203 && parts[1] === 0 && parts[2] === 113)
|| parts[0] >= 224;
};
const isPrivateHostname = (hostname) => {
const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, '');
const ipVersion = isIP(normalized);
return normalized === 'localhost'
|| normalized.endsWith('.localhost')
|| (ipVersion === 6 && (
normalized === '::1'
|| /^f[cd]/.test(normalized)
|| normalized.startsWith('fe80:')
))
|| (ipVersion === 4 && isPrivateIpv4(normalized));
};
export const normalizeLegalUrl = (rawValue, label = 'legal URL') => {
const value = String(rawValue || '').trim();
if (!value) {
throw new Error(`${label} is required`);
}
if (PLACEHOLDER_PATTERN.test(value)) {
throw new Error(`${label} must not use a placeholder or example domain`);
}
let parsed;
try {
parsed = new URL(value);
} catch {
throw new Error(`${label} must be a valid absolute URL`);
}
if (parsed.protocol !== 'https:') {
throw new Error(`${label} must use HTTPS`);
}
if (parsed.username || parsed.password) {
throw new Error(`${label} must not contain embedded credentials`);
}
if (RESERVED_HOSTNAME_PATTERN.test(parsed.hostname)) {
throw new Error(`${label} must not use a reserved hostname`);
}
if (isPrivateHostname(parsed.hostname)) {
throw new Error(`${label} must use a public hostname`);
}
return parsed.toString();
};
export const validateLegalUrlPair = (termsValue, privacyValue) => {
const termsUrl = normalizeLegalUrl(termsValue, 'service agreement URL');
const privacyUrl = normalizeLegalUrl(privacyValue, 'privacy policy URL');
if (termsUrl === privacyUrl) {
throw new Error('service agreement URL and privacy policy URL must be different');
}
return { termsUrl, privacyUrl };
};
const extractLinks = (message) =>
[...String(message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]);
export const validateAndroidPrivacyDocument = (privacy) => {
if (!privacy || typeof privacy !== 'object') {
throw new Error('androidPrivacy.json must contain a JSON object');
}
if (privacy.prompt !== 'template') {
throw new Error('androidPrivacy.json must use prompt=template');
}
if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) {
throw new Error('androidPrivacy.json version must be a positive numeric policy version');
}
if (privacy.hrefLoader !== 'system') {
throw new Error('androidPrivacy.json must open legal links with hrefLoader=system');
}
const primaryLinks = extractLinks(privacy.message);
const secondaryLinks = extractLinks(privacy.second?.message);
if (primaryLinks.length !== 2 || secondaryLinks.length !== 2) {
throw new Error('androidPrivacy.json must contain two legal links in both prompts');
}
const primary = validateLegalUrlPair(primaryLinks[0], primaryLinks[1]);
const secondary = validateLegalUrlPair(secondaryLinks[0], secondaryLinks[1]);
if (primary.termsUrl !== secondary.termsUrl || primary.privacyUrl !== secondary.privacyUrl) {
throw new Error('androidPrivacy.json primary and secondary prompts must use the same legal links');
}
return primary;
};
export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => {
const { termsUrl, privacyUrl } = validateLegalUrlPair(termsValue, privacyValue);
const rendered = String(templateText)
.replaceAll('__TERMS_URL__', termsUrl)
.replaceAll('__PRIVACY_URL__', privacyUrl);
const privacy = JSON.parse(rendered);
validateAndroidPrivacyDocument(privacy);
return `${JSON.stringify(privacy, null, 2)}\n`;
};
@@ -0,0 +1,30 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { renderAndroidPrivacy } from './app-legal-config.mjs';
const projectRoot = resolve(import.meta.dirname, '..');
const templatePath = resolve(projectRoot, 'androidPrivacy.json.example');
// uni-app CLI uses src/ as UNI_INPUT_DIR. The App-Plus compiler only copies
// androidPrivacy.json from that input directory into the native build resource.
const outputPath = resolve(projectRoot, 'src', 'androidPrivacy.json');
const argumentValue = (name) => {
const prefix = `--${name}=`;
return process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length);
};
const termsUrl = argumentValue('terms-url') || process.env.VITE_APP_TERMS_URL;
const privacyUrl = argumentValue('privacy-url') || process.env.VITE_APP_PRIVACY_URL;
try {
const rendered = renderAndroidPrivacy(
readFileSync(templatePath, 'utf8'),
termsUrl,
privacyUrl
);
writeFileSync(outputPath, rendered, 'utf8');
console.log('src/androidPrivacy.json configured from final public legal URLs.');
} catch (error) {
console.error(`Android privacy configuration failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
+266
View File
@@ -0,0 +1,266 @@
[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 (-not [string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) {
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 the legal links cannot truthfully be asserted from the APK.
$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
}
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
}
if (-not $privacyJson.Contains($ExpectedTermsUrl)) {
throw 'Packaged privacy configuration is missing the expected service agreement URL.'
}
if (-not $privacyJson.Contains($ExpectedPrivacyUrl)) {
throw 'Packaged privacy configuration is missing the expected privacy policy URL.'
}
$privacyConfig = $privacyJson | ConvertFrom-Json
if ($privacyConfig.prompt -ne 'template') {
throw 'Packaged privacy configuration must enable the DCloud native template prompt.'
}
}
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' = '(?<!\d)1[3-9]\d{9}(?!\d)'
'fixed test SMS code' = '\u6D4B\u8BD5\u9A8C\u8BC1\u7801.{0,24}\b\d{4,8}\b'
}
$textExtensions = @('.css', '.html', '.js', '.json', '.txt', '.xml')
foreach ($file in Get-ChildItem -LiteralPath $decodePath -Recurse -File) {
if ($textExtensions -notcontains $file.Extension.ToLowerInvariant()) {
continue
}
$content = [IO.File]::ReadAllText($file.FullName)
foreach ($rule in $sensitiveRules.GetEnumerator()) {
if ([regex]::IsMatch($content, $rule.Value)) {
$relativeFile = $file.FullName.Substring($decodePath.Length).TrimStart('\', '/')
throw "APK contains a possible $($rule.Key): $relativeFile"
}
}
}
$androidNamespace = 'http://schemas.android.com/apk/res/android'
$application = $decodedManifest.manifest.application
$debuggable = $application.GetAttribute('debuggable', $androidNamespace) -eq 'true'
$usesCleartextTraffic = $application.GetAttribute('usesCleartextTraffic', $androidNamespace) -eq 'true'
if ($BuildKind -eq 'custom-base' -and -not $debuggable) {
throw 'Custom base APK must remain debuggable.'
}
if ($BuildKind -eq 'dcloud-test' -and $debuggable) {
throw 'DCloud internal test APK must not be debuggable.'
}
if ($BuildKind -eq 'release') {
if ($debuggable) {
throw 'Release APK must not be debuggable.'
}
if ($usesCleartextTraffic) {
throw 'Release APK must not allow cleartext HTTP traffic.'
}
if ([string]::IsNullOrWhiteSpace($ExpectedSignerSha256)) {
throw 'Release verification requires -ExpectedSignerSha256 for the enterprise signing certificate.'
}
}
$signatureOutput = & $java.Source -jar $apksignerJar verify --verbose --print-certs $resolvedApkPath 2>&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
}
}
}
+198 -9
View File
@@ -1,13 +1,77 @@
import { readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { relative, resolve } from 'node:path';
import {
validateAndroidPrivacyDocument,
validateLegalUrlPair
} from './app-legal-config.mjs';
const projectRoot = resolve(import.meta.dirname, '..');
const manifestPath = resolve(projectRoot, 'src/manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const requirePrivacy = process.argv.includes('--require-privacy');
const checkLegalUrls = process.argv.includes('--check-legal-urls');
const failures = [];
let releaseLegalUrls;
const fail = (message) => failures.push(message);
const compiledAppRoot = resolve(projectRoot, 'dist/build/app');
const releaseTextExtensions = new Set(['.css', '.html', '.js', '.json', '.txt', '.xml']);
const releaseSensitiveFilePattern = /(^|[\\/])(?:\.env(?:\.|$)|id_rsa$)|\.(?:jks|keystore|p12|pfx|pem)$/i;
const releaseSensitiveContentPatterns = [
['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', /(?<!\d)1[3-9]\d{9}(?!\d)/],
['fixed test SMS code', /测试验证码.{0,24}\b\d{4,8}\b/]
];
const compiledTextFiles = (root) => {
if (!existsSync(root)) return [];
const result = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolutePath = resolve(directory, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
} else if (releaseTextExtensions.has(entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase())) {
result.push(absolutePath);
}
}
};
visit(root);
return result;
};
const scanCompiledAppForSensitiveValues = () => {
if (!existsSync(compiledAppRoot)) {
fail('compiled App resource directory is missing; run build:app before release verification');
return;
}
const allFiles = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const absolutePath = resolve(directory, entry.name);
if (entry.isDirectory()) visit(absolutePath);
else allFiles.push(absolutePath);
}
};
visit(compiledAppRoot);
for (const file of allFiles) {
const relativePath = relative(compiledAppRoot, file);
if (releaseSensitiveFilePattern.test(relativePath)) {
fail(`compiled App contains a sensitive file: ${relativePath}`);
}
}
for (const file of compiledTextFiles(compiledAppRoot)) {
const content = readFileSync(file, 'utf8');
for (const [label, pattern] of releaseSensitiveContentPatterns) {
if (pattern.test(content)) {
fail(`compiled App contains a possible ${label}: ${relative(compiledAppRoot, file)}`);
}
}
}
};
const readPng = (relativePath) => {
if (typeof relativePath !== 'string' || !relativePath) {
fail('manifest is missing an App asset path');
@@ -49,10 +113,54 @@ const app = manifest['app-plus'];
const distribute = app?.distribute;
const icons = distribute?.icons;
const splashscreen = distribute?.splashscreen;
const android = distribute?.android;
const modules = app?.modules;
if (!Array.isArray(app?.screenOrientation) || !app.screenOrientation.includes('portrait-primary')) {
fail('app-plus.screenOrientation must include portrait-primary');
}
if (splashscreen?.useOriginalMsgbox !== true) {
fail('app-plus.distribute.splashscreen.useOriginalMsgbox must enable the native privacy prompt');
}
if (app?.ssl?.untrustedca !== 'refuse') {
fail('app-plus.ssl.untrustedca must remain refuse');
}
if (!/^\d+\.\d+\.\d+$/.test(String(manifest.versionName || ''))) {
fail('versionName must use semantic numeric form such as 0.1.6');
}
if (!/^[1-9]\d*$/.test(String(manifest.versionCode || ''))) {
fail('versionCode must be a positive integer');
}
if (android?.packagename !== 'com.yincheng.wygj') {
fail('Android package name must remain com.yincheng.wygj');
}
if (android?.targetSdkVersion !== 35) {
fail('Android targetSdkVersion must be 35 for the August 1 test package');
}
const expectedAndroidAbis = ['arm64-v8a', 'armeabi-v7a'];
const actualAndroidAbis = Array.isArray(android?.abiFilters)
? [...android.abiFilters].sort()
: [];
if (JSON.stringify(actualAndroidAbis) !== JSON.stringify(expectedAndroidAbis)) {
fail('Android abiFilters must include exactly armeabi-v7a and arm64-v8a');
}
if (android?.permissionExternalStorage?.request !== 'none') {
fail('Android external storage permission must not be requested at startup');
}
if (android?.permissionPhoneState?.request !== 'none') {
fail('Android phone state permission must not be requested at startup');
}
if (!Object.hasOwn(modules || {}, 'Camera') || !Object.hasOwn(modules || {}, 'Record')) {
fail('App modules must include Camera and Record for user-triggered media features');
}
const declaredAndroidPermissions = Array.isArray(android?.permissions)
? android.permissions.join('\n')
: '';
for (const permission of ['android.permission.RECORD_AUDIO', 'android.permission.MODIFY_AUDIO_SETTINGS']) {
if (!declaredAndroidPermissions.includes(permission)) {
fail(`Android permissions must include ${permission}`);
}
}
const androidIcons = [
['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]
@@ -85,21 +193,102 @@ const splashPaths = [
];
for (const [relativePath, width, height] of splashPaths) expectPng(relativePath, width, height);
const privacyPath = resolve(projectRoot, 'androidPrivacy.json');
const privacyPath = resolve(projectRoot, 'src/androidPrivacy.json');
const compiledPrivacyPath = resolve(projectRoot, 'dist/build/app/androidPrivacy.json');
if (requirePrivacy) {
if (String(process.env.VITE_DEV_SMS_HINT_ENABLED || '').toLowerCase() === 'true') {
fail('VITE_DEV_SMS_HINT_ENABLED must be disabled for release builds');
}
if (String(process.env.VITE_DEV_SMS_HINT_VALUE || '').trim()) {
fail('VITE_DEV_SMS_HINT_VALUE must not be present for release builds');
}
try {
releaseLegalUrls = validateLegalUrlPair(
process.env.VITE_APP_TERMS_URL,
process.env.VITE_APP_PRIVACY_URL
);
} catch (error) {
fail(`release build legal URL environment is invalid: ${error instanceof Error ? error.message : String(error)}`);
}
if (!existsSync(privacyPath)) {
fail('androidPrivacy.json is required for release; copy androidPrivacy.json.example and replace both legal links');
fail('src/androidPrivacy.json is required for release; run configure:app-privacy with final legal URLs');
} else {
try {
const privacy = JSON.parse(readFileSync(privacyPath, 'utf8'));
const links = [...String(privacy.message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]);
if (privacy.prompt !== 'template' || links.length < 2 || links.some((link) => !/^https:\/\//.test(link) || link.includes('__'))) {
fail('androidPrivacy.json must use prompt=template and two final HTTPS legal links');
const privacyLegalUrls = validateAndroidPrivacyDocument(privacy);
if (
releaseLegalUrls
&& (
privacyLegalUrls.termsUrl !== releaseLegalUrls.termsUrl
|| privacyLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl
)
) {
fail('androidPrivacy.json legal links must match VITE_APP_TERMS_URL and VITE_APP_PRIVACY_URL');
}
} catch {
fail('androidPrivacy.json must be valid JSON without comments');
releaseLegalUrls = privacyLegalUrls;
} catch (error) {
fail(`androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (!existsSync(compiledPrivacyPath)) {
fail('compiled App resource is missing androidPrivacy.json; run build:app after configure:app-privacy');
} else {
try {
const compiledPrivacy = JSON.parse(readFileSync(compiledPrivacyPath, 'utf8'));
const compiledLegalUrls = validateAndroidPrivacyDocument(compiledPrivacy);
if (
releaseLegalUrls
&& (
compiledLegalUrls.termsUrl !== releaseLegalUrls.termsUrl
|| compiledLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl
)
) {
fail('compiled App androidPrivacy.json must match the source privacy configuration');
}
} catch (error) {
fail(`compiled App androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
}
}
scanCompiledAppForSensitiveValues();
}
const checkRemoteLegalPage = async (url, label, expectedText) => {
try {
const response = await fetch(url, {
method: 'GET',
redirect: 'follow',
signal: AbortSignal.timeout(10000),
headers: { 'user-agent': 'Bangdao-App-Release-Preflight/1.0' }
});
if (!response.ok) {
fail(`${label} returned HTTP ${response.status}`);
return;
}
if (!String(response.url).startsWith('https://')) {
fail(`${label} redirected away from HTTPS`);
return;
}
const contentType = String(response.headers.get('content-type') || '').toLowerCase();
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml+xml')) {
fail(`${label} must return an HTML document`);
return;
}
const body = await response.text();
if (body.trim().length < 100 || !expectedText.test(body)) {
fail(`${label} does not contain recognizable final legal content`);
}
} catch (error) {
fail(`${label} is not publicly reachable: ${error instanceof Error ? error.message : String(error)}`);
}
};
if (requirePrivacy && checkLegalUrls && releaseLegalUrls) {
await Promise.all([
checkRemoteLegalPage(releaseLegalUrls.termsUrl, 'service agreement URL', /服务协议|用户协议/),
checkRemoteLegalPage(releaseLegalUrls.privacyUrl, 'privacy policy URL', /隐私政策|个人信息保护/)
]);
}
if (failures.length) {