feat: 添加签名配置和网络安全配置;支持清晰流量控制

This commit is contained in:
Booker
2026-05-26 22:59:45 +07:00
parent bb720b227e
commit 568c3a6e08
3 changed files with 222 additions and 112 deletions

View File

@@ -1,6 +1,6 @@
import java.util.UUID
import java.security.SecureRandom
import java.util.Base64
import java.util.Properties
plugins {
id("com.android.application") version "8.9.1"
@@ -654,6 +654,33 @@ val MNEMONIC_WORDS_EXTRA = listOf(
// 合并词表(基础 + 扩展,约 5 倍词库)
val ALL_MNEMONIC_WORDS = MNEMONIC_WORDS + MNEMONIC_WORDS_EXTRA
val DEFAULT_APPLICATION_ID = "io.openim.flutter.openim"
val signingProperties = Properties()
val signingPropertiesFile = rootProject.file("key.properties")
if (signingPropertiesFile.exists()) {
signingPropertiesFile.inputStream().use { signingProperties.load(it) }
}
fun projectOrEnv(projectProperty: String, envName: String): String? {
return project.findProperty(projectProperty)?.toString()?.takeIf { it.isNotBlank() }
?: System.getenv(envName)?.takeIf { it.isNotBlank() }
}
fun signingValue(projectProperty: String, envName: String, vararg propertyNames: String): String? {
return projectOrEnv(projectProperty, envName)
?: propertyNames.firstNotNullOfOrNull { name ->
signingProperties.getProperty(name)?.takeIf { it.isNotBlank() }
}
}
fun isEnabled(value: String?): Boolean {
return value != null && (
value.equals("true", ignoreCase = true) ||
value.equals("yes", ignoreCase = true) ||
value == "1"
)
}
// 助记词风格包名:从合并词表中随机选词,格式 com.{word1}.{word2}.{word3}.{word4}
// 类似冷钱包助记词,易读且符合 Android 包名规范(小写字母)
@@ -662,26 +689,108 @@ fun generateRandomPackageName(): String {
return "com.${words.joinToString(".")}"
}
fun resolveProjectFile(path: String): File {
val directFile = File(path)
if (directFile.isAbsolute) {
return directFile
}
val rootRelativeFile = rootProject.file(path)
return if (rootRelativeFile.exists()) rootRelativeFile else project.file(path)
}
fun randomUrlSafePassword(): String {
val randomBytes = ByteArray(18)
SecureRandom().nextBytes(randomBytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes)
}
fun ensureStableLocalReleaseSigning(): Boolean {
if (signingPropertiesFile.exists()) {
return true
}
val keystoreFile = rootProject.file("app/local-release.jks")
if (keystoreFile.exists()) {
println("⚠️ 已存在本地签名文件但缺少 android/key.properties无法确认密码将使用 debug 签名降级")
return false
}
val keytoolName = if (System.getProperty("os.name").lowercase().contains("win")) "keytool.exe" else "keytool"
val bundledKeytool = File(System.getProperty("java.home"), "bin/$keytoolName")
val keytoolPath = if (bundledKeytool.exists()) bundledKeytool.absolutePath else keytoolName
val keyAlias = "local_release"
val keyPassword = randomUrlSafePassword()
keystoreFile.parentFile.mkdirs()
println("🔐 首次构建:生成本地固定 release 签名 ${keystoreFile.absolutePath}")
val process = try {
ProcessBuilder(
keytoolPath,
"-genkeypair",
"-v",
"-keystore", keystoreFile.absolutePath,
"-alias", keyAlias,
"-keyalg", "RSA",
"-keysize", "2048",
"-validity", "10000",
"-storepass", keyPassword,
"-keypass", keyPassword,
"-dname", "CN=Flutter Shell, OU=Release, O=DanDan, L=Ho Chi Minh, ST=Ho Chi Minh, C=VN"
)
.redirectErrorStream(true)
.start()
} catch (e: Exception) {
println("✗ 执行 keytool 失败: ${e.message}")
return false
}
val output = process.inputStream.bufferedReader().readText()
val exitCode = process.waitFor()
if (exitCode != 0 || !keystoreFile.exists()) {
println("✗ 本地固定签名生成失败: $output")
return false
}
signingPropertiesFile.writeText(
"""
# Generated by android/app/build.gradle.kts. This file is ignored by git.
storeFile=app/local-release.jks
storePassword=$keyPassword
keyAlias=$keyAlias
keyPassword=$keyPassword
""".trimIndent() + "\n"
)
signingProperties.setProperty("storeFile", "app/local-release.jks")
signingProperties.setProperty("storePassword", keyPassword)
signingProperties.setProperty("keyAlias", keyAlias)
signingProperties.setProperty("keyPassword", keyPassword)
println("✓ 已生成 android/key.properties后续 release 构建会复用同一签名")
return true
}
// 包名生成逻辑
// 优先使用手动指定的包名,否则生成完全随机包名
// 优先使用手动指定的包名,否则使用固定默认包名;如确需随机包名,可显式开启 RANDOMIZE_APPLICATION_ID=true
fun computeApplicationId(): String {
val manualAppId = project.findProperty("applicationId") as String?
?: System.getenv("APPLICATION_ID")
val manualAppId = projectOrEnv("applicationId", "APPLICATION_ID")
if (manualAppId != null && manualAppId.isNotBlank()) {
return manualAppId
}
val packageSuffix = project.findProperty("packageSuffix") as String?
?: System.getenv("PACKAGE_SUFFIX")
val packageSuffix = projectOrEnv("packageSuffix", "PACKAGE_SUFFIX")
?: System.getenv("CI_COMMIT_BRANCH")?.replace("/", "_")
?: System.getenv("GITHUB_REF_NAME")?.replace("/", "_")
return if (packageSuffix != null && packageSuffix.isNotBlank()) {
"io.openim.flutter.demo.$packageSuffix"
} else {
generateRandomPackageName()
if (packageSuffix != null && packageSuffix.isNotBlank()) {
return "io.openim.flutter.demo.$packageSuffix"
}
if (isEnabled(projectOrEnv("randomizeApplicationId", "RANDOMIZE_APPLICATION_ID"))) {
return generateRandomPackageName()
}
return DEFAULT_APPLICATION_ID
}
android {
@@ -713,89 +822,80 @@ android {
// Release 签名配置
// 优先级:
// 1. 手动指定的签名(环境变量Gradle 属性)
// 2. 自动生成随机签名(每次打包都不同
// 3. Debug 签名(降级方案
// 1. 手动指定的签名(环境变量Gradle 属性或 android/key.properties
// 2. 显式开启时生成随机签名(GENERATE_RANDOM_KEYSTORE=true兼容旧流程
// 3. 自动生成并复用本地固定签名(默认
// 4. Debug 签名(降级方案)
create("release") {
// 方式1: 手动指定的签名(最高优先级)
val manualKeystoreFile = project.findProperty("keystoreFile") as String?
?: System.getenv("KEYSTORE_FILE")
val manualKeystorePassword = project.findProperty("keystorePassword") as String?
?: System.getenv("KEYSTORE_PASSWORD")
val manualKeyAlias = project.findProperty("keyAlias") as String?
?: System.getenv("KEY_ALIAS")
val manualKeyPassword = project.findProperty("keyPassword") as String?
?: System.getenv("KEY_PASSWORD")
if (manualKeystoreFile != null && manualKeystorePassword != null &&
manualKeyAlias != null && manualKeyPassword != null) {
val keystore = file(manualKeystoreFile)
if (keystore.exists()) {
fun configureFromAvailableSigningProperties(sourceName: String): Boolean {
val configuredKeystoreFile = signingValue("keystoreFile", "KEYSTORE_FILE", "storeFile", "keystoreFile")
val configuredKeystorePassword = signingValue("keystorePassword", "KEYSTORE_PASSWORD", "storePassword", "keystorePassword")
val configuredKeyAlias = signingValue("keyAlias", "KEY_ALIAS", "keyAlias")
val configuredKeyPassword = signingValue("keyPassword", "KEY_PASSWORD", "keyPassword")
val hasPartialConfig = listOf(
configuredKeystoreFile,
configuredKeystorePassword,
configuredKeyAlias,
configuredKeyPassword
).any { it != null }
if (configuredKeystoreFile != null &&
configuredKeystorePassword != null &&
configuredKeyAlias != null &&
configuredKeyPassword != null
) {
val keystore = resolveProjectFile(configuredKeystoreFile)
if (!keystore.exists()) {
println("警告: Keystore 文件不存在: $configuredKeystoreFile")
return false
}
storeFile = keystore
storePassword = manualKeystorePassword
this.keyAlias = manualKeyAlias
this.keyPassword = manualKeyPassword
println("✓ 使用手动指定的 release 签名: $manualKeystoreFile")
return@create
} else {
println("警告: Keystore 文件不存在: $manualKeystoreFile")
storePassword = configuredKeystorePassword
this.keyAlias = configuredKeyAlias
this.keyPassword = configuredKeyPassword
println("✓ 使用${sourceName} release 签名: ${keystore.absolutePath}")
return true
}
if (hasPartialConfig) {
println("警告: release 签名配置不完整,将尝试使用本地固定签名")
}
return false
}
// 方式2: 自动生成随机签名(每次打包都不同)
val generateRandom = System.getenv("GENERATE_RANDOM_KEYSTORE")
?: project.findProperty("generateRandomSigning") as String?
?: "true" // 默认启用随机签名
if (generateRandom == "true") {
// 将签名文件保存在 build 目录,构建清理时会自动删除
if (configureFromAvailableSigningProperties("已配置的")) {
return@create
}
// 方式2: 显式开启时才生成随机签名。默认禁用,避免每次打包都触发未知应用风控。
val generateRandom = projectOrEnv("generateRandomSigning", "GENERATE_RANDOM_KEYSTORE") ?: "false"
if (isEnabled(generateRandom)) {
val buildDir = layout.buildDirectory.get().asFile
val keystoreDir = File(buildDir, "keystore")
keystoreDir.mkdirs()
// 生成唯一的文件名(基于时间戳和随机字符串)
val timestamp = System.currentTimeMillis()
val random = SecureRandom()
val randomBytes = ByteArray(8)
random.nextBytes(randomBytes)
val randomSuffix = Base64.getEncoder().encodeToString(randomBytes)
.replace("=", "")
.replace("/", "_")
.replace("+", "-")
.substring(0, 8)
val randomSuffix = randomUrlSafePassword().take(8)
val keystoreFile = File(keystoreDir, "release_${timestamp}_${randomSuffix}.jks")
val keyAlias = "release_key_${randomSuffix}"
// 生成随机密码16位
val randomPasswordBytes = ByteArray(16)
random.nextBytes(randomPasswordBytes)
val keystorePassword = Base64.getEncoder().encodeToString(randomPasswordBytes)
.replace("=", "")
.substring(0, 16)
// 每次打包都生成新的签名(删除旧的同名文件,如果有)
val keystorePassword = randomUrlSafePassword()
if (keystoreFile.exists()) {
keystoreFile.delete()
}
println("🔐 正在生成随机 release 签名...")
println(" Keystore: ${keystoreFile.absolutePath}")
println(" Alias: $keyAlias")
// 使用当前 JVM 的 keytool避免 CI/容器中 PATH 无 keytool 或 spawn 失败
val keytoolName = if (System.getProperty("os.name").lowercase().contains("win")) "keytool.exe" else "keytool"
val keytoolPath = File(System.getProperty("java.home"), "bin/$keytoolName").absolutePath
val keytoolFile = File(keytoolPath)
if (!keytoolFile.exists()) {
println("✗ 未找到 keytool: $keytoolPath")
println(" 将使用 debug 签名作为降级方案")
return@create
}
val processBuilder = ProcessBuilder(
val keytoolName = if (System.getProperty("os.name").lowercase().contains("win")) "keytool.exe" else "keytool"
val bundledKeytool = File(System.getProperty("java.home"), "bin/$keytoolName")
val keytoolPath = if (bundledKeytool.exists()) bundledKeytool.absolutePath else keytoolName
val process = try {
ProcessBuilder(
keytoolPath,
"-genkey",
"-genkeypair",
"-v",
"-keystore", keystoreFile.absolutePath,
"-alias", keyAlias,
@@ -804,43 +904,38 @@ android {
"-validity", "10000", // 有效期约 27 年
"-storepass", keystorePassword,
"-keypass", keystorePassword,
"-dname", "CN=App, OU=Development, O=Company, L=City, ST=State, C=US"
"-dname", "CN=Flutter Shell, OU=Ephemeral, O=DanDan, L=Ho Chi Minh, ST=Ho Chi Minh, C=VN"
)
val process = try {
processBuilder.start()
} catch (e: Exception) {
println("✗ 执行 keytool 失败 (如 CI/root 环境 spawn 受限): ${e.message}")
println(" 将使用 debug 签名作为降级方案")
return@create
.redirectErrorStream(true)
.start()
} catch (e: Exception) {
println("✗ 执行 keytool 失败: ${e.message}")
null
}
val generated = process?.let {
val output = it.inputStream.bufferedReader().readText()
val exitCode = it.waitFor()
if (exitCode != 0) {
println("✗ 随机签名生成失败: $output")
}
val exitCode = process.waitFor()
if (exitCode == 0) {
if (keystoreFile.exists()) {
println("✓ 随机签名生成成功: ${keystoreFile.absolutePath}")
println(" 文件大小: ${keystoreFile.length()} 字节")
} else {
println("✗ 签名文件生成失败: 文件不存在")
println(" 将使用 debug 签名作为降级方案")
return@create
}
} else {
val errorOutput = process.errorStream.bufferedReader().readText()
println("✗ 签名生成失败: $errorOutput")
println(" 将使用 debug 签名作为降级方案")
return@create
}
storeFile = keystoreFile
storePassword = keystorePassword
this.keyAlias = keyAlias
this.keyPassword = keystorePassword
println("✓ 使用随机生成的 release 签名")
exitCode == 0 && keystoreFile.exists()
} ?: false
if (generated) {
storeFile = keystoreFile
storePassword = keystorePassword
this.keyAlias = keyAlias
this.keyPassword = keystorePassword
println("✓ 使用随机生成的 release 签名")
return@create
}
}
if (ensureStableLocalReleaseSigning() && configureFromAvailableSigningProperties("本地固定")) {
return@create
}
// 方式3: 降级到 debug 签名
println("⚠️ 未配置 release 签名,将使用 debug 签名")
}
}
@@ -858,6 +953,8 @@ android {
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
manifestPlaceholders["networkSecurityConfig"] = "@xml/network_security_config"
manifestPlaceholders["usesCleartextTraffic"] = "true"
// .so 文件加固配置
// 注意:当使用 flutter build apk --split-per-abi 或 --target-platform 时,
@@ -876,6 +973,11 @@ android {
buildTypes {
release {
val allowReleaseCleartext = isEnabled(projectOrEnv("allowReleaseCleartext", "ALLOW_RELEASE_CLEARTEXT"))
manifestPlaceholders["networkSecurityConfig"] =
if (allowReleaseCleartext) "@xml/network_security_config" else "@xml/network_security_config_release"
manifestPlaceholders["usesCleartextTraffic"] = allowReleaseCleartext.toString()
// 如果配置了 release 签名,优先使用;否则使用 debug 签名
val releaseSigningConfig = signingConfigs.findByName("release")
if (releaseSigningConfig != null && releaseSigningConfig.storeFile != null) {

View File

@@ -26,8 +26,8 @@
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="true">
android:networkSecurityConfig="${networkSecurityConfig}"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>