feat: 添加签名配置和网络安全配置;支持清晰流量控制
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user