diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d05e9b1..8eed198 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 17731c9..ce93a0c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -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}"> + + + + + + +