import javax.inject.Inject import groovy.json.JsonSlurper import org.gradle.api.DefaultTask import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.OutputFile import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskAction import org.gradle.process.ExecOperations import org.gradle.work.DisableCachingByDefault /** * Generates every configured RN plugin bundle before Android packages assets. * * Apply once from the host application module: * apply from: file("../../node_modules/@xuqm/rn-update/android/xuqm-bundles.gradle") */ @DisableCachingByDefault(because = "Metro maintains its own transform cache") abstract class GenerateXuqmBundlesTask extends DefaultTask { @Internal abstract DirectoryProperty getProjectRootDirectory() @InputFile @PathSensitive(PathSensitivity.RELATIVE) abstract RegularFileProperty getCliFile() @InputFile @PathSensitive(PathSensitivity.RELATIVE) abstract RegularFileProperty getConfigFile() @InputFile @PathSensitive(PathSensitivity.RELATIVE) abstract RegularFileProperty getPackageFile() /** * RN/原生源码与打包配置共同决定 bundle、资源和 native baseline。 * 显式建模后 Gradle 才能安全复用上次输出,禁止用永久 out-of-date 代替依赖声明。 */ @InputFiles @PathSensitive(PathSensitivity.RELATIVE) abstract ConfigurableFileCollection getSourceFiles() @Input abstract Property getNodeExecutable() @Input abstract Property getAppVersion() @Input abstract Property getBuildId() @Input abstract Property getBugCollectMode() @OutputDirectory abstract DirectoryProperty getAssetsOutputDirectory() @OutputDirectory abstract DirectoryProperty getResourcesOutputDirectory() @Inject abstract ExecOperations getExecOperations() @TaskAction void generate() { def embeddedBundles = assetsOutputDirectory.get().dir("rn-bundles").asFile def resources = resourcesOutputDirectory.get().asFile execOperations.exec { workingDir(projectRootDirectory.get().asFile) environment("XUQM_BUILD_ID", buildId.get()) environment("XUQM_BUGCOLLECT_MODE", bugCollectMode.get()) commandLine( nodeExecutable.get(), cliFile.get().asFile.absolutePath, "embed", "android", "--output", embeddedBundles.absolutePath, "--app-version", appVersion.get(), ) }.assertNormalExitValue() // Android 内嵌 Bundle 通过 AssetManager 读取,但 require() 图片必须同时进入 // aapt2 的 drawable 资源表。只把 drawable-* 放进 assets 会导致图片静默为空。 project.delete(resources) embeddedBundles.listFiles()?.findAll { it.isDirectory() && it.name.startsWith("drawable-") }?.each { drawableDirectory -> project.copy { from(drawableDirectory) into(new File(resources, drawableDirectory.name)) } } } } /** * 所有变体都只复制同一份签名配置。Debug 使用 Metro 时不得生成插件 Bundle, * 但 Android 原生 SDK 仍必须从 assets 完成与 JS 相同的一次自动初始化。 */ @DisableCachingByDefault(because = "Signed config is a small immutable build input") abstract class CopyXuqmConfigTask extends DefaultTask { @InputFile @PathSensitive(PathSensitivity.RELATIVE) abstract RegularFileProperty getSignedConfigFile() @OutputDirectory abstract DirectoryProperty getAssetsOutputDirectory() @TaskAction void copyConfig() { def output = assetsOutputDirectory.get().asFile project.delete(output) project.copy { from(signedConfigFile.get().asFile) into(new File(output, "config")) rename { "config.xuqmconfig" } } } } /** * 为 RN 宿主生成 Android SDK 自动初始化所需的唯一 Manifest 元数据。 * * RN 使用本脚本完成同一份配置驱动接入,不要求宿主再声明 Android Gradle 插件。 * 元数据名称属于 Android SDK 与 RN 桥接的内部协议,不进入宿主业务代码。 */ abstract class GenerateXuqmHostManifestTask extends DefaultTask { @Input abstract Property getBuildId() @Input abstract Property getBugCollectBuildEnabled() @Input abstract Property getBugCollectAutomaticEnabled() @OutputFile abstract RegularFileProperty getOutputFile() @TaskAction void generate() { def manifest = outputFile.get().asFile manifest.parentFile.mkdirs() manifest.setText( """ """, "UTF-8", ) } } if (!plugins.hasPlugin("com.android.application")) { throw new GradleException("xuqm-bundles.gradle must be applied to an Android application module") } def xuqmProjectRoot = rootProject.projectDir.parentFile def xuqmHostPackage = new JsonSlurper().parse(new File(xuqmProjectRoot, "package.json")) def xuqmLocalDependencyDirectories = (xuqmHostPackage.dependencies ?: [:]) .findAll { _, requested -> requested instanceof String && requested.startsWith("file:") } .collect { _, requested -> new File(xuqmProjectRoot, requested.substring("file:".length())).canonicalFile } def xuqmUseMetro = providers.gradleProperty("USE_METRO") .orElse(providers.environmentVariable("USE_METRO")) .map { it.toBoolean() } .orElse(false) def xuqmReleaseVersionCode = providers.gradleProperty("XUQM_VERSION_CODE") .orElse(providers.environmentVariable("XUQM_VERSION_CODE")) if (xuqmReleaseVersionCode.isPresent()) { def parsedVersionCode = Integer.parseInt(xuqmReleaseVersionCode.get()) if (parsedVersionCode <= 0) { throw new GradleException("XUQM_VERSION_CODE must be a positive integer") } android.defaultConfig.versionCode = parsedVersionCode } def xuqmBuildId = providers.gradleProperty("XUQM_BUILD_ID") .orElse(providers.environmentVariable("XUQM_BUILD_ID")) .orElse("development") def xuqmBugCollectMode = providers.gradleProperty("XUQM_BUGCOLLECT_MODE") .orElse(providers.environmentVariable("XUQM_BUGCOLLECT_MODE")) .orElse("platform") if (!["platform", "disabled"].contains(xuqmBugCollectMode.get())) { throw new GradleException("XUQM_BUGCOLLECT_MODE must be platform or disabled") } def xuqmSignedConfigDirectory = new File(xuqmProjectRoot, "src/assets/config") def xuqmSignedConfigCandidates = xuqmSignedConfigDirectory.isDirectory() ? (xuqmSignedConfigDirectory.listFiles()?.findAll { it.isFile() && it.name.toLowerCase(Locale.ROOT).endsWith(".xuqmconfig") } ?: []).sort { left, right -> left.name <=> right.name } : [] if (xuqmSignedConfigCandidates.size() > 1) { throw new GradleException( "src/assets/config must contain exactly one .xuqmconfig file; found ${xuqmSignedConfigCandidates.size()}" ) } def prepareXuqmBundles = tasks.register( "prepareXuqmEmbeddedBundles", GenerateXuqmBundlesTask, ) { group = "xuqm" description = "Builds and embeds all configured Xuqm RN plugin bundles." projectRootDirectory.set(xuqmProjectRoot) cliFile.set(new File(xuqmProjectRoot, "node_modules/@xuqm/rn-update/scripts/xuqm-rn.mjs")) configFile.set(new File(xuqmProjectRoot, "xuqm.modules.json")) packageFile.set(new File(xuqmProjectRoot, "package.json")) sourceFiles.from(project.fileTree(xuqmProjectRoot) { include("src/**") include("assets/**") include("android/**") include("index.*") include("babel.config.*") include("metro.config.*") include("react-native.config.*") include("tsconfig*.json") include("pnpm-lock.yaml") include("yarn.lock") exclude("android/**/build/**") exclude("android/**/.cxx/**") exclude("android/**/.gradle/**") exclude("android/app/src/**/generated/**") }) sourceFiles.from(project.fileTree(new File( xuqmProjectRoot, "node_modules/@xuqm/rn-update/scripts", )) { include("*.mjs") }) // registry 依赖由不可变版本号进入 baseline;file: 依赖在版本不变时也可能修改 // 源码,必须同时成为 Gradle 输入和 native baseline 内容。 // JS/TS 源码决定 bundle 内容,android/ios 决定 native baseline,缺一不可。 xuqmLocalDependencyDirectories.each { dependencyDirectory -> sourceFiles.from(project.fileTree(dependencyDirectory) { include("src/**") include("metro/**") include("android/**") include("ios/**") include("*.podspec") include("package.json") exclude("**/build/**") exclude("**/.cxx/**") exclude("**/.gradle/**") exclude("**/generated/**") }) } nodeExecutable.set(System.getenv("NODE_BINARY") ?: "node") appVersion.set(providers.provider { def version = android.defaultConfig.versionName if (version == null || version.toString().trim().isEmpty()) { throw new GradleException("Android defaultConfig.versionName is required") } version.toString() }) buildId.set(xuqmBuildId) bugCollectMode.set(xuqmBugCollectMode) assetsOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets")) resourcesOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/res")) } def prepareXuqmConfig = tasks.register( "prepareXuqmConfigAssets", CopyXuqmConfigTask, ) { group = "xuqm" description = "Copies the single signed Xuqm config into Android assets." signedConfigFile.set( xuqmSignedConfigCandidates ? xuqmSignedConfigCandidates.first() : new File(xuqmSignedConfigDirectory, "config.xuqmconfig") ) assetsOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/config-assets")) } androidComponents.onVariants(androidComponents.selector().all()) { variant -> def variantName = variant.name def variantSuffix = variantName.substring(0, 1).toUpperCase(Locale.ROOT) + variantName.substring(1) def bugCollectEnabled = xuqmBugCollectMode.map { it != "disabled" } def prepareXuqmHostManifest = tasks.register( "prepareXuqmHostManifest${variantSuffix}", GenerateXuqmHostManifestTask, ) { group = "xuqm" description = "Generates Xuqm initialization metadata for ${variantName}." buildId.set(xuqmBuildId) bugCollectBuildEnabled.set(bugCollectEnabled) bugCollectAutomaticEnabled.set( bugCollectEnabled.map { enabled -> enabled && variant.buildType == "release" }, ) outputFile.set( layout.buildDirectory.file( "generated/xuqm/${variantName}/AndroidManifest.xml", ), ) } variant.sources.manifests.addGeneratedManifestFile( prepareXuqmHostManifest, { task -> task.outputFile }, ) variant.sources.assets?.addGeneratedSourceDirectory( prepareXuqmConfig, { task -> task.assetsOutputDirectory }, ) if (variant.buildType == "release" || !xuqmUseMetro.get()) { variant.sources.assets?.addGeneratedSourceDirectory( prepareXuqmBundles, { task -> task.assetsOutputDirectory }, ) variant.sources.res?.addGeneratedSourceDirectory( prepareXuqmBundles, { task -> task.resourcesOutputDirectory }, ) } } tasks.configureEach { task -> if (task.name ==~ /^createBundle.*ReleaseJsAndAssets$/) { // Xuqm hosts load startup/common/app/plugin bundles explicitly. A second // monolithic index.android.bundle is both redundant and unsafe. task.enabled = false } }