Skip to content

[Bug] ksp-xposed writes assets/xposed_init into source tree, races AGP merge tasks on fresh checkouts #125

@kinginu

Description

@kinginu

Summary

yukihookapi-ksp-xposed writes its assets/xposed_init and resources/META-INF/yukihookapi_init outputs directly into the source tree with File.writeText() instead of going through codeGenerator.createNewFile(). This bypasses Gradle/KSP's task wiring, so on a fresh checkout (CI) AGP's merge{Variant}Assets and merge{Variant}JavaResource tasks can run before ksp{Variant}Kotlin writes the files — and the published APK ships without assets/xposed_init. LSPosed (especially Vector / older flavors that strictly rely on assets/xposed_init) then can't find the entry-class pointer and the Manager's enable toggle silently no-ops, even though the module shows up in the list because its manifest meta-data is read independently.

Locally this is invisible: previous builds leave the files in app/src/main/assets/xposed_init and app/src/main/resources/META-INF/yukihookapi_init. Subsequent assembleRelease runs always see them, so the race never manifests. Every CI build, however, is fresh.

Affected code

yukihookapi-ksp-xposed/src/api/kotlin/com/highcapable/yukihookapi/YukiHookXposedProcessor.kt#L208-L228:

private fun generateAssetsFile(codePath: String, sourcePath: String, data: GenerateData) = environment {
    ...
    val assetsDir = projectDir.resolve(sourcePath).resolve("assets")
    val metaInfDir = projectDir.resolve(sourcePath).resolve("resources").resolve("META-INF")
    ...
    assetsDir.resolve("xposed_init").writeText(text = "${data.entryPackageName}.${data.xInitClassName}")
    metaInfDir.resolve("yukihookapi_init").writeText(text = "${data.entryPackageName}.${data.entryClassName}")
    ...
}

projectDir.resolve(sourcePath) resolves to <module>/src/main/, i.e. into the source tree. Compare with generateClassFile right below, which correctly uses environment.codeGenerator.createNewFile(...) and lands under build/generated/ksp/<variant>/kotlin/.

Reproduction

Any module that uses yukihookapi:api:1.3.x + ksp 'com.highcapable.yukihookapi:ksp-xposed:1.3.1' and adds app/src/main/assets/xposed_init to .gitignore:

# Simulate a CI fresh checkout
rm -f app/src/main/assets/xposed_init \
      app/src/main/resources/META-INF/yukihookapi_init
./gradlew :app:clean :app:assembleRelease

# Inspect packaged APK
unzip -l app/build/outputs/apk/release/app-release-unsigned.apk | grep -E "xposed|yuki"

Result:

40  META-INF/yukihookapi_init

Note the missing assets/xposed_init. Do not run a second assembleRelease — the file from the first failed build now lives in the source tree, masking the bug forever after.

Symptom on devices

End users hit this as: PixelMask shows up in LSPosed Manager's module list (with the correct icon/description, since xposedmodule etc. meta-data is in the manifest), but the enable toggle silently does nothing when tapped. No error is surfaced to the user. With newer LSPosed builds that fall back to reading META-INF/yukihookapi_init and inferring the _YukiHookXposedInit suffix, the module loads anyway. With Vector / older LSPosed that only reads assets/xposed_init, it doesn't.

We hit it in production with PixelMask 1.0.16 — see user report. Diagnosed by diffing the GitHub-released APK against a freshly-cleaned local release build: the only difference was the missing assets/xposed_init.

Workaround

Consumers can paper over this in their app/build.gradle:

android.applicationVariants.configureEach { variant ->
    def cap = variant.name.capitalize()
    def kspTask = "ksp${cap}Kotlin"
    tasks.named("merge${cap}Assets").configure { dependsOn(kspTask) }
    tasks.named("merge${cap}JavaResource").configure { dependsOn(kspTask) }
}

This forces the merge tasks to wait on KSP, so the source-tree files are on disk by the time AGP samples its inputs. Verified to fix the issue; we're shipping it in PixelMask 1.0.17.

Proposed upstream fix

Use environment.codeGenerator.createNewFile(...) for the two text files as well, so KSP places them under build/generated/ksp/<variant>/resources/... and AGP picks them up via its existing KSP wiring. Roughly:

codeGenerator.createNewFile(
    dependencies = Dependencies(aggregating = false),
    packageName = "",
    fileName = "xposed_init",
    extensionName = ""
).bufferedWriter().use { it.write("${data.entryPackageName}.${data.xInitClassName}") }

(plus the equivalent for META-INF/yukihookapi_init).

If there's a reason to keep writing into the source tree (legacy IDE behaviour? user-friendly inspectability?), an alternative fix is for the KSP plugin to register the file as a Gradle task output and wire merge{Variant}Assets / merge{Variant}JavaResource to depend on it — i.e. do the workaround above on behalf of all consumers.

Happy to send a PR if a direction is settled. Thanks for the great library.

Environment

  • YukiHookAPI 1.3.1 (api + ksp-xposed)
  • KSP 2.2.10-2.0.2
  • Kotlin 2.2.10
  • AGP 8.13.2
  • Affected: any CI / fresh-checkout build path
  • Not affected (silently): local incremental builds where the previous run left the file in src/main/

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions