fix(android): use login consent instead of native privacy prompt
This commit is contained in:
@@ -74,43 +74,39 @@ export const validateLegalUrlPair = (termsValue, privacyValue) => {
|
||||
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 (privacy.prompt !== 'none') {
|
||||
throw new Error('androidPrivacy.json must use prompt=none for the controlled internal App');
|
||||
}
|
||||
if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) {
|
||||
throw new Error('androidPrivacy.json version must be a positive numeric policy version');
|
||||
throw new Error('androidPrivacy.json version must be a positive numeric configuration version');
|
||||
}
|
||||
if (privacy.hrefLoader !== 'system') {
|
||||
throw new Error('androidPrivacy.json must open legal links with hrefLoader=system');
|
||||
for (const templateOnlyField of [
|
||||
'title',
|
||||
'message',
|
||||
'buttonAccept',
|
||||
'buttonRefuse',
|
||||
'hrefLoader',
|
||||
'second',
|
||||
'disagreeMode',
|
||||
'styles'
|
||||
]) {
|
||||
if (Object.hasOwn(privacy, templateOnlyField)) {
|
||||
throw new Error(`androidPrivacy.json prompt=none must not contain ${templateOnlyField}`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
return {
|
||||
prompt: privacy.prompt,
|
||||
version: String(privacy.version)
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
validateLegalUrlPair(termsValue, privacyValue);
|
||||
const privacy = JSON.parse(String(templateText));
|
||||
validateAndroidPrivacyDocument(privacy);
|
||||
return `${JSON.stringify(privacy, null, 2)}\n`;
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@ try {
|
||||
privacyUrl
|
||||
);
|
||||
writeFileSync(outputPath, rendered, 'utf8');
|
||||
console.log('src/androidPrivacy.json configured from final public legal URLs.');
|
||||
console.log('src/androidPrivacy.json configured with prompt=none; final public legal URLs validated for the login page.');
|
||||
} catch (error) {
|
||||
console.error(`Android privacy configuration failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -107,37 +107,71 @@ try {
|
||||
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
|
||||
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.'
|
||||
}
|
||||
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
|
||||
$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'
|
||||
)
|
||||
}
|
||||
|
||||
if (-not $privacyJson.Contains($ExpectedTermsUrl)) {
|
||||
throw 'Packaged privacy configuration is missing the expected service agreement URL.'
|
||||
$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 $privacyJson.Contains($ExpectedPrivacyUrl)) {
|
||||
throw 'Packaged privacy configuration is missing the expected privacy policy URL.'
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($ExpectedTermsUrl)) {
|
||||
if ($legalAppServiceFiles.Count -ne 1) {
|
||||
throw "Expected exactly one App service for login legal-link verification, found $($legalAppServiceFiles.Count)."
|
||||
}
|
||||
$privacyConfig = $privacyJson | ConvertFrom-Json
|
||||
if ($privacyConfig.prompt -ne 'template') {
|
||||
throw 'Packaged privacy configuration must enable the DCloud native template prompt.'
|
||||
$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.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
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 requireLegalConsent = process.argv.includes('--require-legal-consent');
|
||||
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 compiledManifestPath = resolve(compiledAppRoot, 'manifest.json');
|
||||
const compiledAppServicePath = resolve(compiledAppRoot, 'app-service.js');
|
||||
const loginPagePath = resolve(projectRoot, 'src/pages/auth/login/index.vue');
|
||||
const sourceAndroidManifestPath = resolve(projectRoot, 'AndroidManifest.xml');
|
||||
const compiledAndroidManifestPath = resolve(compiledAppRoot, 'AndroidManifest.xml');
|
||||
const sourceNetworkSecurityPath = resolve(
|
||||
@@ -129,8 +132,8 @@ 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 (splashscreen?.useOriginalMsgbox !== false) {
|
||||
fail('app-plus.distribute.splashscreen.useOriginalMsgbox must disable the native privacy prompt');
|
||||
}
|
||||
if (app?.ssl?.untrustedca !== 'refuse') {
|
||||
fail('app-plus.ssl.untrustedca must remain refuse');
|
||||
@@ -248,7 +251,7 @@ for (const [relativePath, width, height] of splashPaths) expectPng(relativePath,
|
||||
|
||||
const privacyPath = resolve(projectRoot, 'src/androidPrivacy.json');
|
||||
const compiledPrivacyPath = resolve(projectRoot, 'dist/build/app/androidPrivacy.json');
|
||||
if (requirePrivacy) {
|
||||
if (requireLegalConsent) {
|
||||
if (String(process.env.VITE_DEV_SMS_HINT_ENABLED || '').toLowerCase() === 'true') {
|
||||
fail('VITE_DEV_SMS_HINT_ENABLED must be disabled for release builds');
|
||||
}
|
||||
@@ -265,45 +268,78 @@ if (requirePrivacy) {
|
||||
}
|
||||
|
||||
if (!existsSync(privacyPath)) {
|
||||
fail('src/androidPrivacy.json is required for release; run configure:app-privacy with final legal URLs');
|
||||
fail('src/androidPrivacy.json is required for release; run configure:app-legal with final legal URLs');
|
||||
} else {
|
||||
try {
|
||||
const privacy = JSON.parse(readFileSync(privacyPath, 'utf8'));
|
||||
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');
|
||||
}
|
||||
releaseLegalUrls = privacyLegalUrls;
|
||||
validateAndroidPrivacyDocument(privacy);
|
||||
} 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');
|
||||
fail('compiled App resource is missing androidPrivacy.json; run build:app after configure:app-legal');
|
||||
} else {
|
||||
try {
|
||||
const compiledPrivacy = JSON.parse(readFileSync(compiledPrivacyPath, 'utf8'));
|
||||
const compiledLegalUrls = validateAndroidPrivacyDocument(compiledPrivacy);
|
||||
validateAndroidPrivacyDocument(compiledPrivacy);
|
||||
if (
|
||||
releaseLegalUrls
|
||||
&& (
|
||||
compiledLegalUrls.termsUrl !== releaseLegalUrls.termsUrl
|
||||
|| compiledLegalUrls.privacyUrl !== releaseLegalUrls.privacyUrl
|
||||
)
|
||||
existsSync(privacyPath)
|
||||
&& JSON.stringify(compiledPrivacy) !== JSON.stringify(JSON.parse(readFileSync(privacyPath, 'utf8')))
|
||||
) {
|
||||
fail('compiled App androidPrivacy.json must match the source privacy configuration');
|
||||
fail('compiled App androidPrivacy.json must match the source prompt=none configuration');
|
||||
}
|
||||
} catch (error) {
|
||||
fail(`compiled App androidPrivacy.json is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(compiledManifestPath)) {
|
||||
fail('compiled App resource is missing manifest.json; run build:app');
|
||||
} else {
|
||||
try {
|
||||
const compiledManifest = JSON.parse(readFileSync(compiledManifestPath, 'utf8'));
|
||||
if (compiledManifest.plus?.distribute?.splashscreen?.useOriginalMsgbox !== false) {
|
||||
fail('compiled App manifest must disable the DCloud native privacy prompt');
|
||||
}
|
||||
} catch (error) {
|
||||
fail(`compiled App manifest is invalid: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(loginPagePath)) {
|
||||
fail('login page is missing');
|
||||
} else {
|
||||
const loginSource = readFileSync(loginPagePath, 'utf8');
|
||||
for (const [label, pattern] of [
|
||||
['default unchecked consent', /const agreed = ref\(false\)/],
|
||||
['shared legal consent gate', /const ensureLegalConsent = \(\) =>/],
|
||||
['terms link', /openLegalDocument\('terms'\)/],
|
||||
['privacy link', /openLegalDocument\('privacy'\)/],
|
||||
['SMS legal gate', /const sendCode = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/],
|
||||
['login legal gate', /const login = async \(\) => \{[\s\S]*?if \(!ensureLegalConsent\(\)\) return;/]
|
||||
]) {
|
||||
if (!pattern.test(loginSource)) {
|
||||
fail(`login page is missing ${label}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(compiledAppServicePath)) {
|
||||
fail('compiled App resource is missing app-service.js; run build:app');
|
||||
} else if (releaseLegalUrls) {
|
||||
const appService = readFileSync(compiledAppServicePath, 'utf8');
|
||||
if (!appService.includes(releaseLegalUrls.termsUrl)) {
|
||||
fail('compiled App login flow is missing VITE_APP_TERMS_URL');
|
||||
}
|
||||
if (!appService.includes(releaseLegalUrls.privacyUrl)) {
|
||||
fail('compiled App login flow is missing VITE_APP_PRIVACY_URL');
|
||||
}
|
||||
if (!appService.includes('请先阅读并勾选同意')) {
|
||||
fail('compiled App login flow is missing the unchecked-consent failure message');
|
||||
}
|
||||
}
|
||||
verifyAndroidNetworkSecurity(
|
||||
compiledAndroidManifestPath,
|
||||
compiledNetworkSecurityPath,
|
||||
@@ -342,7 +378,7 @@ const checkRemoteLegalPage = async (url, label, expectedText) => {
|
||||
}
|
||||
};
|
||||
|
||||
if (requirePrivacy && checkLegalUrls && releaseLegalUrls) {
|
||||
if (requireLegalConsent && checkLegalUrls && releaseLegalUrls) {
|
||||
await Promise.all([
|
||||
checkRemoteLegalPage(releaseLegalUrls.termsUrl, 'service agreement URL', /服务协议|用户协议/),
|
||||
checkRemoteLegalPage(releaseLegalUrls.privacyUrl, 'privacy policy URL', /隐私政策|个人信息保护/)
|
||||
|
||||
Reference in New Issue
Block a user