├── .github └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── RELEASING.md ├── agp-patch ├── build.gradle ├── gradle.properties └── src │ └── main │ └── kotlin │ ├── app │ └── cash │ │ └── better │ │ └── dynamic │ │ └── features │ │ ├── AgpPatchPlugin.kt │ │ └── BetterDynamicFeaturesPatchExtension.kt │ └── com │ └── android │ └── build │ └── gradle │ └── internal │ ├── DependencyConfigurator.kt │ └── dependency │ └── DexingTransform.kt ├── build.gradle ├── buildLogic ├── convention │ ├── build.gradle │ └── src │ │ └── main │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── better │ │ └── dynamic │ │ └── features │ │ └── Convention.kt └── settings.gradle ├── codegen ├── api │ ├── build.gradle │ ├── gradle.properties │ └── src │ │ └── main │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── better │ │ └── dynamic │ │ └── features │ │ └── codegen │ │ └── api │ │ ├── Constants.kt │ │ ├── FeatureApi.kt │ │ └── FeatureImplementation.kt ├── build.gradle ├── gradle.properties ├── ksp │ ├── build.gradle │ ├── gradle.properties │ └── src │ │ ├── main │ │ ├── kotlin │ │ │ └── app │ │ │ │ └── cash │ │ │ │ └── better │ │ │ │ └── dynamic │ │ │ │ └── features │ │ │ │ └── codegen │ │ │ │ └── ksp │ │ │ │ ├── DynamicFeaturesSymbolProcessorProvider.kt │ │ │ │ └── FeatureModuleSymbolProcessor.kt │ │ └── resources │ │ │ └── META-INF │ │ │ └── services │ │ │ └── com.google.devtools.ksp.processing.SymbolProcessorProvider │ │ └── test │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── better │ │ └── dynamic │ │ └── features │ │ └── codegen │ │ └── ksp │ │ └── FeatureModuleSymbolProcessorTests.kt └── src │ ├── main │ └── kotlin │ │ └── app │ │ └── cash │ │ └── better │ │ └── dynamic │ │ └── features │ │ └── codegen │ │ ├── GenerateImplementationsContainer.kt │ │ └── GenerateProguardRules.kt │ └── test │ └── kotlin │ └── app │ └── cash │ └── better │ └── dynamic │ └── features │ └── codegen │ └── GenerateImplementationsContainerTest.kt ├── gradle-plugin ├── build.gradle ├── gradle.properties ├── libs │ └── ARSCLib-1.1.5.jar └── src │ ├── main │ └── kotlin │ │ └── app │ │ └── cash │ │ └── better │ │ └── dynamic │ │ └── features │ │ ├── BetterDynamicFeaturesExtension.kt │ │ ├── BetterDynamicFeaturesFeatureExtension.kt │ │ ├── BetterDynamicFeaturesPlugin.kt │ │ ├── codegen │ │ ├── AndroidVariant.kt │ │ ├── CompileTypesafeImplementations.kt │ │ ├── TypesafeImplementationsCompilationTask.kt │ │ └── TypesafeImplementationsGeneratorTask.kt │ │ └── tasks │ │ ├── BaseLockfileWriterTask.kt │ │ ├── CheckExternalResourcesTask.kt │ │ ├── CheckLockfileTask.kt │ │ ├── DependencyGraph.kt │ │ ├── DependencyGraphConsumingTask.kt │ │ ├── DependencyGraphUtils.kt │ │ ├── DependencyGraphWriterTask.kt │ │ ├── GenerateExternalResourcesTask.kt │ │ ├── LockfileEntry.kt │ │ └── ResourceDumpingTask.kt │ └── test │ ├── fixtures │ ├── buildscript.gradle │ ├── codegen-fixture │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── ExampleFeature.kt │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── ExampleImplementation.kt │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── custom-build-type │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── AndroidManifest.xml │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── AndroidManifest.xml │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── custom-lockfile │ │ ├── base │ │ │ ├── .gitignore │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── different-versions-in-base │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── different-versions │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── feature-compile-classpath │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── has-constraints │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── lockfile-activation │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── gradle.lockfile │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── missing-dex │ │ ├── base │ │ │ ├── build.gradle │ │ │ ├── gradle.lockfile │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── AndroidManifest.xml │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── AndroidManifest.xml │ │ ├── gradle.properties │ │ ├── library │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── java │ │ │ │ └── com │ │ │ │ │ └── example │ │ │ │ │ └── JavaClass.java │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── KotlinClass.kt │ │ └── settings.gradle │ ├── multiplatform-dependency │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── new-transitive-dependency │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── out-of-date │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── project-dependency │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ ├── library │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── HelloWorld.kt │ │ └── settings.gradle │ ├── resources-correct-declaration │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── strings.xml │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── example │ │ │ │ │ └── HelloWorldActivity.kt │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── styles.xml │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── resources-in-base │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ ├── strings.xml │ │ │ │ └── styles.xml │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── HelloWorldActivity.kt │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── resources-missing-declaration │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── strings.xml │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ ├── kotlin │ │ │ │ └── com │ │ │ │ │ └── example │ │ │ │ │ └── HelloWorldActivity.kt │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── styles.xml │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── resources-normal │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── strings.xml │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── HelloWorldActivity.kt │ │ ├── gradle.properties │ │ ├── library │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── styles.xml │ │ └── settings.gradle │ ├── resources-overwriting │ │ ├── base │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── strings.xml │ │ ├── build.gradle │ │ ├── feature │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── HelloWorldActivity.kt │ │ ├── gradle.properties │ │ ├── library │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── res │ │ │ │ └── values │ │ │ │ └── styles.xml │ │ └── settings.gradle │ ├── same-versions │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── settings.gradle │ ├── transitive-dependency-on-base-variant-aware │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ ├── library │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── HelloWorld.kt │ │ └── settings.gradle │ ├── transitive-dependency-on-base │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ ├── library │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── HelloWorld.kt │ │ └── settings.gradle │ ├── transitive-dependency │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ ├── library │ │ │ ├── build.gradle │ │ │ └── src │ │ │ │ └── main │ │ │ │ └── kotlin │ │ │ │ └── com │ │ │ │ └── example │ │ │ │ └── HelloWorld.kt │ │ └── settings.gradle │ ├── up-to-date │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ ├── variant-aware │ │ ├── base │ │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ └── version-update │ │ ├── base │ │ └── build.gradle │ │ ├── build.gradle │ │ ├── feature │ │ └── build.gradle │ │ ├── gradle.properties │ │ └── settings.gradle │ └── java │ └── app │ └── cash │ └── better │ └── dynamic │ └── features │ ├── BetterDynamicFeaturesPluginTest.kt │ ├── CodegenIntegrationTests.kt │ ├── ResourceScanningTests.kt │ ├── TestUtils.kt │ └── utils │ └── SequenceSubject.kt ├── gradle.properties ├── gradle ├── libs.versions.toml ├── license-header.txt └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── renovate.json ├── runtime ├── build.gradle ├── gradle.properties ├── jvm │ ├── build.gradle │ ├── gradle.properties │ └── src │ │ └── main │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── better │ │ └── dynamic │ │ └── features │ │ ├── DynamicApi.kt │ │ ├── DynamicImplementation.kt │ │ ├── ExperimentalDynamicFeaturesApi.kt │ │ └── ImplementationsContainer.kt └── src │ └── main │ └── kotlin │ └── app │ └── cash │ └── better │ └── dynamic │ └── features │ └── DynamicImplementations.kt ├── sample ├── .gitattributes ├── .gitignore ├── README.md ├── app │ ├── build.gradle │ ├── gradle.lockfile │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ ├── kotlin │ │ └── app │ │ │ └── cash │ │ │ └── boxapp │ │ │ ├── api │ │ │ ├── BoxAppFeature.kt │ │ │ ├── Navigator.kt │ │ │ └── ServiceRegistry.kt │ │ │ ├── install │ │ │ ├── Module.kt │ │ │ └── SplitInstallHelper.kt │ │ │ └── ui │ │ │ ├── HomeScreen.kt │ │ │ └── MainActivity.kt │ │ └── res │ │ └── values │ │ └── strings.xml ├── bigboxfeature │ ├── api │ │ ├── build.gradle │ │ └── src │ │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── kotlin │ │ │ └── app │ │ │ └── cash │ │ │ └── boxapp │ │ │ └── bigboxfeature │ │ │ └── api │ │ │ └── BigBoxMainScreen.kt │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── boxapp │ │ └── bigboxfeature │ │ ├── BigBoxFeature.kt │ │ └── BigBoxScreen.kt ├── build.gradle ├── docs │ └── boxapp.png ├── extrabigboxfeature │ ├── build.gradle │ └── src │ │ └── main │ │ ├── AndroidManifest.xml │ │ └── kotlin │ │ └── app │ │ └── cash │ │ └── boxapp │ │ └── extrabigboxfeature │ │ └── ExtraBigBoxFeature.kt ├── gradle.properties ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── settings.gradle └── smallboxfeature │ ├── build.gradle │ └── src │ └── main │ ├── AndroidManifest.xml │ └── kotlin │ └── app │ └── cash │ └── boxapp │ └── smallboxfeature │ ├── SmallBoxFeature.kt │ └── SmallBoxWidget.kt └── settings.gradle /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish a release 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | tags: [ '*' ] 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | if: github.repository == 'cashapp/better-dynamic-features' 13 | permissions: 14 | contents: read 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-java@v3.14.1 19 | with: 20 | distribution: 'zulu' 21 | java-version: 17 22 | 23 | - name: Publish Artifacts 24 | env: 25 | ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.SONATYPE_CENTRAL_USERNAME }} 26 | ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.SONATYPE_CENTRAL_PASSWORD }} 27 | ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }} 28 | ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_SECRET_PASSPHRASE }} 29 | run: ./gradlew publishAllPublicationsToMavenCentralRepository 30 | 31 | env: 32 | GRADLE_OPTS: -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs="-Xmx2G -XX:+HeapDumpOnOutOfMemoryError" 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: {} 7 | 8 | jobs: 9 | run_tests: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-java@v3.14.1 15 | with: 16 | distribution: 'zulu' 17 | java-version: 17 18 | 19 | - name: Run tests 20 | run: ./gradlew check --stacktrace 21 | 22 | - name: Store Test Results 23 | uses: actions/upload-artifact@v4 24 | if: failure() 25 | with: 26 | name: error-report 27 | path: "**/build/reports/tests/test/**/*.html" 28 | 29 | bundle_sample: 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: actions/setup-java@v3.14.1 35 | with: 36 | distribution: 'zulu' 37 | java-version: 17 38 | 39 | - name: Bundle Sample Project 40 | # TODO: Remove the extra runtime build command 41 | run: | 42 | ./gradlew :runtime:build 43 | ./gradlew -p sample app:bundleRelease 44 | 45 | env: 46 | GRADLE_OPTS: -Dorg.gradle.parallel=true -Dorg.gradle.caching=true -Dorg.gradle.jvmargs="-Xmx2G -XX:+HeapDumpOnOutOfMemoryError" 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .idea/ 5 | .DS_Store 6 | **/build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | 12 | !**/com/android/build 13 | 14 | **/fixtures/**/gradle.lockfile 15 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [0.5.2-agp8.11.0] - 2025-06-25 4 | 5 | * Bump to AGP 8.11.0 (#195) 6 | 7 | ## [0.5.2-agp8.10.1] - 2025-05-07 8 | 9 | * Bump to AGP 8.10.1 (#196) 10 | * Bump compileSdk and targetSdk to 36 (#192) 11 | * Gradle cleanup (#191, #190) 12 | * Fix compile error in tests (#186) 13 | * Bump Wire to 5.3.1 (#185) 14 | * Switch to Compose BOM (#184) 15 | 16 | ## [0.5.1-agp8.10.0] - 2025-05-07 17 | 18 | * Bump to AGP 8.10.0 (#148) 19 | * Bump Kotlin to 2.1.20 (#164) 20 | 21 | ## [0.5.1-agp8.9.2] - 2025-04-25 22 | 23 | * Bump to AGP 8.9.2 (#168) 24 | * Support KSP integration for custom build types (#146) 25 | 26 | ## [0.5.0-agp8.9.0] - 2025-03-04 27 | 28 | * Bump to AGP 8.9.0 and Gradle 8.12.1 (#138) 29 | 30 | ## [0.5.0-agp8.8.1] - 2025-01-14 31 | 32 | * Bump to AGP 8.8.1 (#140) 33 | 34 | ## [0.5.0-agp8.8.0] - 2025-01-14 35 | 36 | * Bump to AGP 8.8.0 and Gradle 8.10.2 (#131) 37 | 38 | ## [0.5.0-agp8.7.3] - 2024-12-11 39 | 40 | * Bump to AGP 8.7.3 (#135) 41 | 42 | ## [0.5.0-agp8.7.2] - 2024-11-20 43 | 44 | * Bump to AGP 8.7.2 (#133) 45 | 46 | ## [0.5.0-agp8.7.1] - 2024-10-18 47 | 48 | * Bump to AGP 8.7.1 (#132) 49 | 50 | ## [0.5.0-agp8.6.1] - 2024-09-18 51 | 52 | * Bump to AGP 8.6.1 (#130) 53 | 54 | ## [0.5.0-agp8.5.1] - 2024-07-24 55 | 56 | * Bump to AGP 8.5.1 (#128) 57 | 58 | ## [0.5.0-agp8.5.0] - 2024-07-03 59 | 60 | * Bump to AGP 8.5.0 (#123) 61 | * Change to new versioning format 62 | 63 | ## [0.4.1] - 2024-06-20 64 | 65 | * Bump to AGP 8.4.2 (#124) 66 | 67 | ## [0.4.0] - 2024-05-29 68 | 69 | * Bump to AGP 8.4.1 (#121) 70 | 71 | ## [0.3.1] - 2024-05-01 72 | 73 | * Put the gradle styles in quotes in the error message (#118) 74 | * Bump to AGP 8.3.2 (#120) 75 | 76 | ## [0.3.0] - 2024-03-14 77 | 78 | * Upgrade AGP to 8.3.0 (#117) 79 | * Recursively search for `DynamicApi` supertype (#114) 80 | * Fixed KSP configuration for flavored builds (#115) 81 | 82 | ## [0.2.2] - 2024-01-24 83 | 84 | * Upgrade AGP to 8.2.2 85 | 86 | ## [0.2.1] - 2024-01-22 87 | 88 | * Upgrade AGP to 8.2.1 (#108) 89 | 90 | ## [0.2.0] - 2023-12-11 91 | 92 | * Upgrade AGP to 8.2.0 and Gradle 8.2 (#106) 93 | 94 | ## [0.1.3] - 2023-11-17 95 | 96 | * Upgrade AGP to 8.1.4 97 | 98 | ## [0.1.2] - 2023-09-22 99 | 100 | * Upgrade AGP to 8.1.1 (#104) 101 | * Fix gradle plugin applying `unspecified` version of KSP and runtime dependencies to projects. (#103) 102 | 103 | ## [0.1.1] - 2023-07-31 104 | 105 | * Remove an unnecessary println message 106 | 107 | ## [0.1.0] - 2023-07-25 108 | 109 | Initial non-snapshot release. 110 | 111 | * `agp-patch` targeting Android Gradle Plugin 8.1.0 112 | * Initial experimental code generation functionality. 113 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | 1. Change the version in `gradle.properties` to a non-SNAPSHOT verson. 4 | 2. Update the `CHANGELOG.md` for the impending release. 5 | 3. `git commit -am "Prepare for release X.Y.Z."` (where X.Y.Z is the new version) 6 | 4. `git tag -a X.Y.Z -m "Version X.Y.Z"` (where X.Y.Z is the new version) 7 | 5. Update the `gradle.properties` to the next SNAPSHOT version. 8 | 6. `git commit -am "Prepare next development version."` 9 | 7. `git push && git push --tags` 10 | 8. Wait until the "Publish a release" action completes, then visit [Sonatype Nexus](https://oss.sonatype.org/) and promote the artifacts. 11 | -------------------------------------------------------------------------------- /agp-patch/build.gradle: -------------------------------------------------------------------------------- 1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 2 | 3 | plugins { 4 | id("app.cash.better-dynamic-features.convention") 5 | id("java-gradle-plugin") 6 | alias(libs.plugins.publish) 7 | } 8 | 9 | gradlePlugin { 10 | plugins { 11 | agpPatch { 12 | id = "app.cash.better.dynamic.features.agp-patch" 13 | implementationClass = 'app.cash.better.dynamic.features.AgpPatchPlugin' 14 | } 15 | } 16 | } 17 | 18 | sourceSets { 19 | main.kotlin.srcDir "$buildDir/gen" 20 | } 21 | 22 | dependencies { 23 | implementation gradleApi() 24 | 25 | compileOnly(libs.agp) 26 | compileOnly(libs.google.guava) 27 | compileOnly(libs.google.gson) 28 | compileOnly(libs.android.common) 29 | compileOnly(libs.android.sdkCommon) 30 | compileOnly(libs.android.repository) 31 | } 32 | 33 | def pluginConstants = tasks.register("pluginConstants") { 34 | def outputDir = file("$buildDir/gen") 35 | 36 | outputs.dir(outputDir) 37 | 38 | doLast { 39 | def versionFile = file("$outputDir/app/cash/better/dynamic/features/Constants.kt") 40 | versionFile.parentFile.mkdirs() 41 | versionFile.text = """// Generated file. Do not edit! 42 | package app.cash.better.dynamic.features 43 | 44 | val TARGET_AGP_VERSION = "${libs.versions.agp.get()}" 45 | """ 46 | } 47 | } 48 | 49 | tasks.withType(KotlinCompile).configureEach { dependsOn(pluginConstants) } 50 | tasks.withType(Jar).configureEach { dependsOn(pluginConstants) } 51 | -------------------------------------------------------------------------------- /agp-patch/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=agp-patch 2 | POM_NAME=Better Dynamic Feature AGP Patch 3 | POM_DESCRIPTION=A patch of the Android Gradle Plugin 4 | -------------------------------------------------------------------------------- /agp-patch/src/main/kotlin/app/cash/better/dynamic/features/AgpPatchPlugin.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features 17 | 18 | import com.android.Version 19 | import com.android.build.gradle.internal.crash.afterEvaluate 20 | import org.gradle.api.Plugin 21 | import org.gradle.api.Project 22 | 23 | class AgpPatchPlugin : Plugin { 24 | override fun apply(target: Project) { 25 | val extension = target.extensions.create("betterDynamicFeaturesPatch", BetterDynamicFeaturesPatchExtension::class.java) 26 | 27 | target.plugins.withId("com.android.application") { 28 | target.afterEvaluate { 29 | val agpVersion = Version.ANDROID_GRADLE_PLUGIN_VERSION 30 | // Skip the version check if we ignored the current AGP version! 31 | if (agpVersion in extension.ignoredVersions) { 32 | target.logger.info("Version compatibility check for Android Gradle Plugin $agpVersion skipped.") 33 | return@afterEvaluate 34 | } 35 | 36 | // Do a strict version check to ensure that our monkey-patching will work correctly. 37 | check(agpVersion == TARGET_AGP_VERSION) { 38 | "This version of the Android Gradle Plugin ($agpVersion) is not supported by the better-dynamic-features plugin. Only version $TARGET_AGP_VERSION is supported." 39 | } 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /agp-patch/src/main/kotlin/app/cash/better/dynamic/features/BetterDynamicFeaturesPatchExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features 17 | 18 | open class BetterDynamicFeaturesPatchExtension { 19 | internal val ignoredVersions = mutableSetOf() 20 | 21 | fun suppressVersionCheck(version: String) { 22 | ignoredVersions += version 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.library) apply false 3 | alias(libs.plugins.kotlin.jvm) apply false 4 | alias(libs.plugins.kotlin.android) apply false 5 | alias(libs.plugins.publish) 6 | alias(libs.plugins.spotless) 7 | alias(libs.plugins.wire) apply false 8 | } 9 | 10 | spotless { 11 | kotlin { 12 | target "**/*.kt" 13 | targetExclude "gradle-plugin/src/test/fixtures/**/*.*", "**/com/android/build/gradle/internal/**/*.*" 14 | ktlint(libs.versions.ktlint.get()).editorConfigOverride([ 15 | "indent_size" : "2", 16 | "disabled_rules" : "package-name", 17 | "ij_kotlin_allow_trailing_comma" : "true", 18 | "ij_kotlin_allow_trailing_comma_on_call_site": "true", 19 | ]) 20 | trimTrailingWhitespace() 21 | endWithNewline() 22 | licenseHeaderFile(rootProject.file('gradle/license-header.txt')) 23 | } 24 | } 25 | 26 | subprojects { 27 | plugins.withId("com.vanniktech.maven.publish") { 28 | publishing { 29 | repositories { 30 | maven { 31 | name = "installLocally" 32 | url = "${rootProject.buildDir}/localMaven" 33 | } 34 | /** 35 | * Want to push to an internal repository for testing? 36 | * Set the following properties in ~/.gradle/gradle.properties. 37 | * 38 | * internalUrl=YOUR_INTERNAL_URL 39 | * internalUsername=YOUR_USERNAME 40 | * internalPassword=YOUR_PASSWORD 41 | */ 42 | maven { 43 | name = "internal" 44 | url = providers.gradleProperty("internalUrl") 45 | credentials(PasswordCredentials) 46 | } 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /buildLogic/convention/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.kotlin.jvm) 3 | id("java-gradle-plugin") 4 | } 5 | 6 | gradlePlugin { 7 | plugins { 8 | convention { 9 | id = "app.cash.better-dynamic-features.convention" 10 | implementationClass = "app.cash.better.dynamic.features.Convention" 11 | } 12 | } 13 | } 14 | 15 | dependencies { 16 | compileOnly(libs.kotlin.gradle) 17 | } 18 | -------------------------------------------------------------------------------- /buildLogic/convention/src/main/kotlin/app/cash/better/dynamic/features/Convention.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features 17 | 18 | import org.gradle.api.Plugin 19 | import org.gradle.api.Project 20 | import org.gradle.api.tasks.compile.JavaCompile 21 | import org.gradle.jvm.toolchain.JavaLanguageVersion 22 | import org.gradle.jvm.toolchain.JvmVendorSpec 23 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget 24 | import org.jetbrains.kotlin.gradle.dsl.kotlinExtension 25 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 26 | 27 | class Convention : Plugin { 28 | override fun apply(target: Project) { 29 | target.plugins.apply("org.jetbrains.kotlin.jvm") 30 | 31 | target.kotlinExtension.jvmToolchain { spec -> 32 | spec.languageVersion.set(JavaLanguageVersion.of(TOOLCHAIN_JDK)) 33 | spec.vendor.set(JvmVendorSpec.AZUL) 34 | } 35 | 36 | target.tasks.withType(KotlinCompile::class.java).configureEach { task -> 37 | task.compilerOptions { 38 | jvmTarget.set(JvmTarget.fromTarget(TARGET_JDK.toString())) 39 | } 40 | } 41 | 42 | target.tasks.withType(JavaCompile::class.java).configureEach { task -> 43 | task.sourceCompatibility = TARGET_JDK.toString() 44 | task.targetCompatibility = TARGET_JDK.toString() 45 | } 46 | } 47 | 48 | private companion object { 49 | const val TOOLCHAIN_JDK = 17 50 | const val TARGET_JDK = 11 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /buildLogic/settings.gradle: -------------------------------------------------------------------------------- 1 | dependencyResolutionManagement { 2 | repositories { 3 | mavenCentral() 4 | google() 5 | } 6 | 7 | versionCatalogs { 8 | libs { 9 | from(files("../gradle/libs.versions.toml")) 10 | } 11 | } 12 | } 13 | 14 | rootProject.name = "buildLogic" 15 | 16 | include(":convention") 17 | -------------------------------------------------------------------------------- /codegen/api/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("app.cash.better-dynamic-features.convention") 3 | alias(libs.plugins.ksp) 4 | alias(libs.plugins.publish) 5 | alias(libs.plugins.spotless) 6 | } 7 | 8 | dependencies { 9 | ksp(libs.moshi.codegen) 10 | 11 | implementation(libs.moshi.core) 12 | } 13 | -------------------------------------------------------------------------------- /codegen/api/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=codegen-api 2 | POM_NAME=Better Dynamic Feature Codegen Api 3 | POM_DESCRIPTION=Internal APIs for the Better Dynamic Features code generator 4 | -------------------------------------------------------------------------------- /codegen/api/src/main/kotlin/app/cash/better/dynamic/features/codegen/api/Constants.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen.api 17 | 18 | const val KSP_REPORT_DIRECTORY_PREFIX = "better-dynamic-features-path" 19 | 20 | const val RUNTIME_IMPLEMENTATION_ANNOTATION = "app.cash.better.dynamic.features.DynamicImplementation" 21 | const val RUNTIME_API_INTERFACE = "app.cash.better.dynamic.features.DynamicApi" 22 | -------------------------------------------------------------------------------- /codegen/api/src/main/kotlin/app/cash/better/dynamic/features/codegen/api/FeatureApi.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen.api 17 | 18 | import com.squareup.moshi.JsonClass 19 | 20 | @JsonClass(generateAdapter = true) 21 | data class FeatureApi( 22 | val packageName: String, 23 | val className: String, 24 | ) 25 | -------------------------------------------------------------------------------- /codegen/api/src/main/kotlin/app/cash/better/dynamic/features/codegen/api/FeatureImplementation.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen.api 17 | 18 | import com.squareup.moshi.JsonClass 19 | 20 | @JsonClass(generateAdapter = true) 21 | data class FeatureImplementation( 22 | val qualifiedName: String, 23 | val parentClass: FeatureApi, 24 | ) 25 | -------------------------------------------------------------------------------- /codegen/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("app.cash.better-dynamic-features.convention") 3 | alias(libs.plugins.publish) 4 | alias(libs.plugins.spotless) 5 | } 6 | 7 | dependencies { 8 | implementation(projects.codegen.api) 9 | 10 | implementation(libs.ksp.api) 11 | implementation(libs.kotlinPoet.core) 12 | implementation(libs.kotlinPoet.ksp) 13 | implementation(libs.moshi.core) 14 | implementation(libs.moshi.kotlin) 15 | 16 | testImplementation(libs.junit) 17 | testImplementation(libs.truth) 18 | } 19 | -------------------------------------------------------------------------------- /codegen/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=codegen 2 | POM_NAME=Better Dynamic Feature Code Generator 3 | POM_DESCRIPTION=Code generation logic for Better Dynamic Features 4 | -------------------------------------------------------------------------------- /codegen/ksp/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("app.cash.better-dynamic-features.convention") 3 | alias(libs.plugins.publish) 4 | alias(libs.plugins.spotless) 5 | } 6 | 7 | dependencies { 8 | implementation(libs.ksp.api) 9 | implementation(libs.moshi.core) 10 | implementation(libs.moshi.kotlin) 11 | 12 | implementation(projects.codegen.api) 13 | 14 | testImplementation(libs.compiler.testing.core) 15 | testImplementation(libs.compiler.testing.ksp) 16 | testImplementation(libs.junit) 17 | testImplementation(libs.truth) 18 | testRuntimeOnly(projects.runtime.jvm) 19 | } 20 | -------------------------------------------------------------------------------- /codegen/ksp/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=codegen-ksp 2 | POM_NAME=Better Dynamic Feature KSP 3 | POM_DESCRIPTION=Kotlin Sympbol Processor for Better Dynamic Features 4 | -------------------------------------------------------------------------------- /codegen/ksp/src/main/kotlin/app/cash/better/dynamic/features/codegen/ksp/DynamicFeaturesSymbolProcessorProvider.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen.ksp 17 | 18 | import com.google.devtools.ksp.processing.SymbolProcessor 19 | import com.google.devtools.ksp.processing.SymbolProcessorEnvironment 20 | import com.google.devtools.ksp.processing.SymbolProcessorProvider 21 | 22 | class DynamicFeaturesSymbolProcessorProvider : SymbolProcessorProvider { 23 | override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = 24 | FeatureModuleSymbolProcessor(environment) 25 | } 26 | -------------------------------------------------------------------------------- /codegen/ksp/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider: -------------------------------------------------------------------------------- 1 | app.cash.better.dynamic.features.codegen.ksp.DynamicFeaturesSymbolProcessorProvider 2 | -------------------------------------------------------------------------------- /codegen/src/main/kotlin/app/cash/better/dynamic/features/codegen/GenerateImplementationsContainer.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | @file:Suppress("PrivatePropertyName") 17 | 18 | package app.cash.better.dynamic.features.codegen 19 | 20 | import app.cash.better.dynamic.features.codegen.api.FeatureApi 21 | import app.cash.better.dynamic.features.codegen.api.FeatureImplementation 22 | import com.squareup.kotlinpoet.AnnotationSpec 23 | import com.squareup.kotlinpoet.ClassName 24 | import com.squareup.kotlinpoet.CodeBlock 25 | import com.squareup.kotlinpoet.FileSpec 26 | import com.squareup.kotlinpoet.FunSpec 27 | import com.squareup.kotlinpoet.KModifier.OVERRIDE 28 | import com.squareup.kotlinpoet.MemberName 29 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy 30 | import com.squareup.kotlinpoet.TypeSpec 31 | import com.squareup.kotlinpoet.asTypeName 32 | 33 | private val TYPE_IMPLEMENTATIONS_CONTAINER = 34 | ClassName("app.cash.better.dynamic.features", "ImplementationsContainer") 35 | 36 | fun generateImplementationsContainer( 37 | forApi: FeatureApi, 38 | implementations: List, 39 | ): FileSpec { 40 | val apiTypeName = ClassName(forApi.packageName, forApi.className) 41 | val fileSpec = FileSpec.builder(forApi.packageName, "${forApi.className}ImplementationsContainer") 42 | 43 | val implementationsFunction = FunSpec.builder("buildImplementations") 44 | .returns(List::class.asTypeName().parameterizedBy(apiTypeName)) 45 | .addModifiers(OVERRIDE) 46 | .addCode( 47 | CodeBlock.builder() 48 | .beginControlFlow("return %M", MemberName("kotlin.collections", "buildList")) 49 | .apply { 50 | implementations.forEach { implementation -> 51 | beginControlFlow("try") 52 | addStatement( 53 | "add(Class.forName(%S).getDeclaredConstructor().newInstance() as %T)", 54 | implementation.qualifiedName, 55 | apiTypeName, 56 | ) 57 | nextControlFlow("catch(e: %T)", ClassName("java.lang", "ClassNotFoundException")) 58 | endControlFlow() 59 | } 60 | } 61 | .endControlFlow().build(), 62 | ) 63 | .build() 64 | 65 | val objectSpec = TypeSpec.objectBuilder("${forApi.className}ImplementationsContainer") 66 | .addSuperinterface(TYPE_IMPLEMENTATIONS_CONTAINER.parameterizedBy(apiTypeName)) 67 | .addAnnotation( 68 | AnnotationSpec.builder(ClassName.bestGuess("kotlin.OptIn")) 69 | .addMember( 70 | "%T::class", 71 | ClassName("app.cash.better.dynamic.features", "ExperimentalDynamicFeaturesApi"), 72 | ).build(), 73 | ) 74 | .addFunction(implementationsFunction) 75 | .build() 76 | 77 | return fileSpec.addType(objectSpec).build() 78 | } 79 | -------------------------------------------------------------------------------- /codegen/src/main/kotlin/app/cash/better/dynamic/features/codegen/GenerateProguardRules.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen 17 | 18 | import app.cash.better.dynamic.features.codegen.api.FeatureApi 19 | import app.cash.better.dynamic.features.codegen.api.FeatureImplementation 20 | import com.squareup.kotlinpoet.ClassName 21 | 22 | fun generateProguardRules( 23 | forApi: FeatureApi, 24 | implementations: List, 25 | ): String = buildString { 26 | val forApiClass = ClassName(forApi.packageName, forApi.className) 27 | // Keep feature API interface itself 28 | appendLine("-keepnames class ${forApiClass.canonicalName}") 29 | // Keep generated implementations container looked up by reflection 30 | appendLine("-keep class ${forApiClass.canonicalName}ImplementationsContainer { *; }") 31 | 32 | // Keep each implementation class and its empty constructor 33 | implementations.forEach { implementation -> 34 | appendLine("-keep class ${implementation.qualifiedName} {") 35 | appendLine(" public ();") 36 | appendLine("}") 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /codegen/src/test/kotlin/app/cash/better/dynamic/features/codegen/GenerateImplementationsContainerTest.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen 17 | 18 | import app.cash.better.dynamic.features.codegen.api.FeatureApi 19 | import app.cash.better.dynamic.features.codegen.api.FeatureImplementation 20 | import com.google.common.truth.Truth.assertThat 21 | import org.junit.Test 22 | 23 | class GenerateImplementationsContainerTest { 24 | @Test 25 | fun `codegen for an api and implementations`() { 26 | val api = FeatureApi("test", "TestApi") 27 | val implementations = listOf( 28 | FeatureImplementation("test.TestImplementation1", api), 29 | FeatureImplementation("test.TestImplementation2", api), 30 | ) 31 | 32 | val spec = generateImplementationsContainer(forApi = api, implementations) 33 | assertThat(spec.toString()) 34 | .isEqualTo( 35 | """ 36 | package test 37 | 38 | import app.cash.better.`dynamic`.features.ExperimentalDynamicFeaturesApi 39 | import app.cash.better.`dynamic`.features.ImplementationsContainer 40 | import java.lang.ClassNotFoundException 41 | import kotlin.OptIn 42 | import kotlin.collections.List 43 | import kotlin.collections.buildList 44 | 45 | @OptIn(ExperimentalDynamicFeaturesApi::class) 46 | public object TestApiImplementationsContainer : ImplementationsContainer { 47 | public override fun buildImplementations(): List = buildList { 48 | try { 49 | add(Class.forName("test.TestImplementation1").getDeclaredConstructor().newInstance() as 50 | TestApi) 51 | } catch(e: ClassNotFoundException) { 52 | } 53 | try { 54 | add(Class.forName("test.TestImplementation2").getDeclaredConstructor().newInstance() as 55 | TestApi) 56 | } catch(e: ClassNotFoundException) { 57 | } 58 | } 59 | } 60 | 61 | """.trimIndent(), 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /gradle-plugin/build.gradle: -------------------------------------------------------------------------------- 1 | import com.google.devtools.ksp.gradle.KspTaskJvm 2 | import com.squareup.wire.gradle.WireTask 3 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 4 | 5 | plugins { 6 | id("app.cash.better-dynamic-features.convention") 7 | alias(libs.plugins.ksp) 8 | id("java-gradle-plugin") 9 | alias(libs.plugins.publish) 10 | alias(libs.plugins.spotless) 11 | alias(libs.plugins.wire) 12 | } 13 | 14 | gradlePlugin { 15 | plugins { 16 | betterDynamicFeatures { 17 | id = 'app.cash.better.dynamic.features' 18 | implementationClass = 'app.cash.better.dynamic.features.BetterDynamicFeaturesPlugin' 19 | } 20 | } 21 | } 22 | 23 | sourceSets { 24 | main.kotlin.srcDir "$buildDir/gen" 25 | } 26 | 27 | dependencies { 28 | ksp(libs.moshi.codegen) 29 | 30 | compileOnly(libs.kotlin.compiler) 31 | 32 | implementation(projects.codegen) 33 | implementation(projects.codegen.api) 34 | 35 | implementation gradleApi() 36 | implementation(libs.agp) 37 | implementation(libs.javassist) 38 | implementation(libs.kotlin.gradle) 39 | implementation(libs.kotlinPoet.core) 40 | implementation(libs.ksp.gradlePlugin) 41 | implementation(libs.moshi.core) 42 | implementation(libs.moshi.kotlin) 43 | implementation(files("libs/ARSCLib-1.1.5.jar")) 44 | 45 | testImplementation(libs.kotlin.compiler) 46 | testImplementation(libs.junit) 47 | testImplementation(libs.truth) 48 | } 49 | 50 | def pluginVersion = tasks.register("pluginVersion") { 51 | def outputDir = file("$buildDir/gen") 52 | def rootPropertiesFile = file("$rootProject.projectDir/gradle.properties") 53 | 54 | inputs.file rootPropertiesFile 55 | outputs.dir outputDir 56 | 57 | doLast { 58 | def rootProperties = new Properties() 59 | rootPropertiesFile.withInputStream { 60 | rootProperties.load(it) 61 | } 62 | 63 | def versionFile = file("$outputDir/app/cash/better/dynamic/features/Version.kt") 64 | versionFile.parentFile.mkdirs() 65 | versionFile.text = """// Generated file. Do not edit! 66 | package app.cash.better.dynamic.features 67 | 68 | val VERSION = "${rootProperties.getProperty("VERSION_NAME")}" 69 | val KOTLIN_VERSION = "${libs.versions.kotlin.get()}" 70 | """ 71 | } 72 | } 73 | 74 | tasks.withType(KotlinCompile).configureEach { dependsOn(pluginVersion) } 75 | tasks.withType(KspTaskJvm).configureEach { dependsOn(pluginVersion) } 76 | 77 | wire { 78 | kotlin {} 79 | } 80 | 81 | // https://github.com/square/wire/issues/2335 82 | afterEvaluate { 83 | tasks.withType(KspTaskJvm).configureEach { 84 | dependsOn(tasks.withType(WireTask)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /gradle-plugin/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=gradle-plugin 2 | POM_NAME=Better Dynamic Feature Gradle Plugin 3 | POM_DESCRIPTION=Gradle Plugin for Better Dynamic Features 4 | -------------------------------------------------------------------------------- /gradle-plugin/libs/ARSCLib-1.1.5.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cashapp/better-dynamic-features/0f004fdbe1227cc0177d3dc095c1dd0e205adb35/gradle-plugin/libs/ARSCLib-1.1.5.jar -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/BetterDynamicFeaturesExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features 17 | 18 | import groovy.lang.Closure 19 | import org.gradle.util.internal.ConfigureUtil 20 | 21 | open class BetterDynamicFeaturesExtension { 22 | internal val externalStyles = mutableListOf() 23 | 24 | fun externalResources(block: Closure) { 25 | val dsl = ExternalResourcesDsl() 26 | ConfigureUtil.configure(block, dsl) 27 | 28 | externalStyles += dsl.styles 29 | } 30 | 31 | fun externalResources(block: ExternalResourcesDsl.() -> Unit) { 32 | externalStyles += ExternalResourcesDsl().apply(block).styles 33 | } 34 | 35 | class ExternalResourcesDsl { 36 | internal val styles = mutableListOf() 37 | 38 | fun style(name: String) { 39 | styles += name 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/BetterDynamicFeaturesFeatureExtension.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features 17 | 18 | import org.gradle.api.Project 19 | import org.gradle.api.provider.Property 20 | 21 | abstract class BetterDynamicFeaturesFeatureExtension { 22 | abstract val baseProject: Property 23 | } 24 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/codegen/AndroidVariant.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen 17 | 18 | import org.gradle.api.Named 19 | import org.gradle.api.attributes.Attribute 20 | 21 | interface AndroidVariant : Named { 22 | companion object { 23 | val ANDROID_VARIANT_ATTRIBUTE: Attribute = Attribute.of("androidVariant", AndroidVariant::class.java) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/codegen/TypesafeImplementationsCompilationTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen 17 | 18 | import org.gradle.api.DefaultTask 19 | import org.gradle.api.file.ConfigurableFileCollection 20 | import org.gradle.api.file.Directory 21 | import org.gradle.api.file.DirectoryProperty 22 | import org.gradle.api.file.RegularFile 23 | import org.gradle.api.file.RegularFileProperty 24 | import org.gradle.api.provider.ListProperty 25 | import org.gradle.api.tasks.Classpath 26 | import org.gradle.api.tasks.InputDirectory 27 | import org.gradle.api.tasks.InputFiles 28 | import org.gradle.api.tasks.OutputFile 29 | import org.gradle.api.tasks.TaskAction 30 | import org.gradle.workers.WorkParameters 31 | import org.gradle.workers.WorkerExecutor 32 | import javax.inject.Inject 33 | 34 | abstract class TypesafeImplementationsCompilationTask : DefaultTask() { 35 | @get:InputFiles 36 | abstract val projectJars: ListProperty 37 | 38 | @get:InputFiles 39 | abstract val projectClasses: ListProperty 40 | 41 | @get:OutputFile 42 | abstract val output: RegularFileProperty 43 | 44 | @get:InputDirectory 45 | abstract val generatedSources: DirectoryProperty 46 | 47 | @get:Classpath 48 | abstract val compileClasspath: ConfigurableFileCollection 49 | 50 | @get:Classpath 51 | abstract val kotlinCompiler: ConfigurableFileCollection 52 | 53 | @get:Inject 54 | abstract val workerExecutor: WorkerExecutor 55 | 56 | interface TypesafeImplementationsCompilationWorkParameters : WorkParameters { 57 | val projectJars: ListProperty 58 | val projectClasses: ListProperty 59 | val output: RegularFileProperty 60 | val generatedSources: DirectoryProperty 61 | val compileClasspath: ConfigurableFileCollection 62 | val temporaryDir: DirectoryProperty 63 | } 64 | 65 | @TaskAction 66 | fun processImplementations() { 67 | val tempClassDirectory = temporaryDir.resolve("classes").also { it.mkdirs() } 68 | 69 | val workQueue = workerExecutor.classLoaderIsolation { 70 | it.classpath.from(kotlinCompiler) 71 | } 72 | 73 | workQueue.submit(CompileTypesafeImplementations::class.java) { parameters -> 74 | parameters.projectJars.set(projectJars) 75 | parameters.projectClasses.set(projectClasses) 76 | parameters.output.set(output) 77 | parameters.generatedSources.set(generatedSources) 78 | parameters.compileClasspath.setFrom(compileClasspath) 79 | parameters.temporaryDir.set(tempClassDirectory) 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/codegen/TypesafeImplementationsGeneratorTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.codegen 17 | 18 | import app.cash.better.dynamic.features.codegen.api.FeatureImplementation 19 | import com.squareup.moshi.Moshi 20 | import okio.buffer 21 | import okio.source 22 | import org.gradle.api.DefaultTask 23 | import org.gradle.api.file.ConfigurableFileCollection 24 | import org.gradle.api.file.DirectoryProperty 25 | import org.gradle.api.file.RegularFileProperty 26 | import org.gradle.api.tasks.InputFiles 27 | import org.gradle.api.tasks.OutputDirectory 28 | import org.gradle.api.tasks.OutputFile 29 | import org.gradle.api.tasks.TaskAction 30 | 31 | abstract class TypesafeImplementationsGeneratorTask : DefaultTask() { 32 | @get:InputFiles 33 | abstract val featureImplementationReports: ConfigurableFileCollection 34 | 35 | @get:OutputFile 36 | abstract val generatedProguardFile: RegularFileProperty 37 | 38 | @get:OutputDirectory 39 | abstract val generatedFilesDirectory: DirectoryProperty 40 | 41 | private val moshi = Moshi.Builder().build() 42 | private val featureAdapter = moshi.adapter(FeatureImplementation::class.java) 43 | 44 | private val generatedSourcesDirectory get() = generatedFilesDirectory.asFile.get() 45 | 46 | @TaskAction 47 | fun generate() { 48 | generatedSourcesDirectory.mkdirs() 49 | 50 | val collectedFeatures = featureImplementationReports.files 51 | .flatMap { dir -> buildList { dir.walk().forEach { if (it.isFile) add(it) } } } 52 | .mapNotNull { it.source().buffer().use { buffer -> featureAdapter.fromJson(buffer) } } 53 | .groupBy { it.parentClass } 54 | 55 | collectedFeatures.forEach { (api, implementations) -> 56 | val fileSpec = 57 | generateImplementationsContainer(forApi = api, implementations = implementations) 58 | fileSpec.writeTo(directory = generatedSourcesDirectory) 59 | } 60 | 61 | val proguardFile = generatedProguardFile.asFile.get() 62 | if (proguardFile.exists()) { 63 | proguardFile.delete() 64 | proguardFile.createNewFile() 65 | } 66 | proguardFile.bufferedWriter().use { writer -> 67 | collectedFeatures.forEach { (api, implementations) -> 68 | writer.append(generateProguardRules(forApi = api, implementations)) 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/tasks/BaseLockfileWriterTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.tasks 17 | 18 | import org.gradle.api.file.RegularFileProperty 19 | import org.gradle.api.tasks.OutputFile 20 | import org.gradle.api.tasks.TaskAction 21 | 22 | abstract class BaseLockfileWriterTask : DependencyGraphConsumingTask() { 23 | @get:OutputFile 24 | abstract val outputLockfile: RegularFileProperty 25 | 26 | @TaskAction 27 | fun generateLockfile() { 28 | val entries = mergeGraphs(baseGraph(), featureGraphs()) 29 | outputLockfile.get().asFile.writeText(entries.toText()) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/tasks/CheckLockfileTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.tasks 17 | 18 | import org.gradle.api.file.RegularFileProperty 19 | import org.gradle.api.provider.Property 20 | import org.gradle.api.tasks.Input 21 | import org.gradle.api.tasks.InputFile 22 | import org.gradle.api.tasks.Internal 23 | import org.gradle.api.tasks.Optional 24 | import org.gradle.api.tasks.OutputFile 25 | import org.gradle.api.tasks.TaskAction 26 | import java.io.File 27 | 28 | abstract class CheckLockfileTask : DependencyGraphConsumingTask() { 29 | @get:Internal 30 | abstract val currentLockfilePath: RegularFileProperty 31 | 32 | @get:[Optional InputFile] 33 | val currentLockfile: File? 34 | get() = currentLockfilePath.get().asFile.takeIf { it.exists() } 35 | 36 | // Used for caching 37 | @get:OutputFile 38 | abstract val outputFile: RegularFileProperty 39 | 40 | @get:Input 41 | abstract val projectPath: Property 42 | 43 | @TaskAction 44 | fun checkLockfiles() { 45 | val currentEntries = mergeGraphs(baseGraph(), featureGraphs()) 46 | val areTheyEqual = currentEntries.toText() == currentLockfile?.readText() 47 | 48 | outputFile.asFile.get().writeText(areTheyEqual.toString()) 49 | if (!areTheyEqual) { 50 | currentLockfilePath.asFile.get().writeText(currentEntries.toText()) 51 | throw IllegalStateException("The lockfile was out of date and has been updated. Rerun your build.") 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/tasks/DependencyGraph.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.tasks 17 | 18 | import com.squareup.moshi.JsonClass 19 | import org.gradle.util.internal.VersionNumber 20 | 21 | @JsonClass(generateAdapter = true) 22 | data class Node( 23 | val artifact: String, 24 | val version: Version, 25 | val variants: Set, 26 | val children: List, 27 | val isProjectModule: Boolean, 28 | val type: DependencyType, 29 | ) 30 | 31 | enum class DependencyType { 32 | Runtime, 33 | Compile; 34 | } 35 | 36 | @JsonClass(generateAdapter = true) 37 | data class NodeList(val nodes: List) 38 | 39 | @JsonClass(generateAdapter = true) 40 | @JvmInline 41 | value class Version(val string: String) : Comparable { 42 | override fun compareTo(other: Version): Int = 43 | VersionNumber.parse(string).compareTo(VersionNumber.parse(other.string)) 44 | 45 | override fun toString(): String = string 46 | } 47 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/tasks/DependencyGraphConsumingTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.tasks 17 | 18 | import com.squareup.moshi.Moshi 19 | import org.gradle.api.DefaultTask 20 | import org.gradle.api.file.ConfigurableFileCollection 21 | import org.gradle.api.tasks.InputFiles 22 | 23 | abstract class DependencyGraphConsumingTask : DefaultTask() { 24 | private val moshi = Moshi.Builder().build() 25 | private fun adapter() = moshi.adapter(NodeList::class.java) 26 | 27 | /** 28 | * Dependency graph files for each AGP variant of every feature module. 29 | */ 30 | @get:InputFiles 31 | abstract val featureDependencyGraphFiles: ConfigurableFileCollection 32 | 33 | /** 34 | * Dependency graph files for every AGP variant of the base module. 35 | */ 36 | @get:InputFiles 37 | abstract val baseDependencyGraphFiles: ConfigurableFileCollection 38 | 39 | protected fun baseGraph() = baseDependencyGraphFiles.map { 40 | adapter().fromJson(it.readText())?.nodes ?: error("Could not read graph from $it") 41 | }.flatten() 42 | 43 | protected fun featureGraphs() = featureDependencyGraphFiles.map { 44 | adapter().fromJson(it.readText())?.nodes ?: error("Could not read graph from $it") 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/tasks/DependencyGraphUtils.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.tasks 17 | 18 | import app.cash.better.dynamic.features.tasks.DependencyType.Compile 19 | import app.cash.better.dynamic.features.tasks.DependencyType.Runtime 20 | 21 | fun List.toText(): String = """ 22 | |# This is a Gradle generated file for dependency locking. 23 | |# Manual edits can break the build and are not advised. 24 | |# This file is expected to be part of source control. 25 | |${sorted().joinToString(separator = "\n")} 26 | |empty= 27 | """.trimMargin() 28 | 29 | private val Node.configurationNames 30 | get() = variants.mapTo(mutableSetOf()) { 31 | when (type) { 32 | Compile -> "${it}CompileClasspath" 33 | Runtime -> "${it}RuntimeClasspath" 34 | } 35 | } 36 | 37 | private fun mergeSingleTypeGraphs( 38 | base: List, 39 | others: List>, 40 | ): Map { 41 | val graphMap = mutableMapOf() 42 | 43 | fun registerNodes(nodes: List) { 44 | nodes.walkAll { node -> 45 | when (val existing = graphMap[node.artifact]) { 46 | null -> graphMap[node.artifact] = node 47 | else -> { 48 | if (node.version > existing.version) { 49 | graphMap[node.artifact] = node.copy(variants = node.variants + existing.variants) 50 | } else { 51 | graphMap[existing.artifact] = existing.copy(variants = existing.variants + node.variants) 52 | } 53 | } 54 | } 55 | } 56 | } 57 | 58 | // Register the initial set of dependencies 59 | registerNodes(base) 60 | 61 | others.flatten().walkAll { node -> 62 | val existing = graphMap[node.artifact] 63 | // We only care about the conflicting dependencies that actually exist in the base 64 | if (existing != null && node.variants.single() in existing.variants) { 65 | if (node.version > existing.version) { 66 | graphMap[node.artifact] = node.copy(variants = node.variants + existing.variants) 67 | } else { 68 | graphMap[existing.artifact] = existing.copy(variants = existing.variants + node.variants) 69 | } 70 | 71 | // Register any new transitive dependencies 72 | registerNodes(node.children) 73 | } 74 | } 75 | 76 | return graphMap 77 | .filter { (_, entry) -> !entry.isProjectModule } 78 | .map { (_, entry) -> 79 | LockfileEntry( 80 | entry.artifact, 81 | entry.version.toString(), 82 | entry.configurationNames, 83 | ) 84 | } 85 | .associateBy { it.artifact } 86 | } 87 | 88 | fun mergeGraphs(base: List, others: List>): List { 89 | val runtimeMap = mergeSingleTypeGraphs( 90 | base.filter { it.type == Runtime }, 91 | others.map { list -> list.filter { it.type == Runtime } }, 92 | ) 93 | val compileMap = mergeSingleTypeGraphs( 94 | base.filter { it.type == Compile }, 95 | others.map { list -> list.filter { it.type == Compile } }, 96 | ).toMutableMap() 97 | 98 | // Merge the runtime and compile graphs 99 | val mergedMap = mutableMapOf() 100 | runtimeMap.forEach { (artifact, entry) -> 101 | mergedMap[artifact] = entry 102 | val compileEntry = compileMap[artifact] ?: return@forEach 103 | 104 | mergedMap[artifact] = entry.copy( 105 | version = maxOf(entry.version, compileEntry.version), 106 | configurations = entry.configurations + compileEntry.configurations, 107 | ) 108 | 109 | compileMap.remove(artifact) 110 | } 111 | compileMap.forEach { (artifact, entry) -> 112 | mergedMap[artifact] = entry 113 | } 114 | 115 | return mergedMap.values.toList() 116 | } 117 | 118 | private tailrec fun List.walkAll(callback: (Node) -> Unit) { 119 | forEach(callback) 120 | val nextLevel = flatMap { it.children } 121 | 122 | if (nextLevel.isNotEmpty()) { 123 | nextLevel.walkAll(callback) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/tasks/DependencyGraphWriterTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.tasks 17 | 18 | import com.squareup.moshi.Moshi 19 | import org.gradle.api.DefaultTask 20 | import org.gradle.api.artifacts.component.ProjectComponentIdentifier 21 | import org.gradle.api.artifacts.result.DependencyResult 22 | import org.gradle.api.artifacts.result.ResolvedComponentResult 23 | import org.gradle.api.artifacts.result.ResolvedDependencyResult 24 | import org.gradle.api.file.ConfigurableFileCollection 25 | import org.gradle.api.file.RegularFileProperty 26 | import org.gradle.api.provider.Provider 27 | import org.gradle.api.tasks.InputFiles 28 | import org.gradle.api.tasks.OutputFile 29 | import org.gradle.api.tasks.TaskAction 30 | 31 | abstract class DependencyGraphWriterTask : DefaultTask() { 32 | /** 33 | * This is only used to trick Gradle into recognizing that this task is no longer up-to-date 34 | * For actual processing, [graph] is used. 35 | */ 36 | @get:InputFiles 37 | abstract val dependencyFileCollection: ConfigurableFileCollection 38 | 39 | private lateinit var graph: Provider> 40 | 41 | fun setResolvedLockfileEntriesProvider(provider: Provider, variant: String) { 42 | graph = provider.map { (runtime, compile) -> 43 | val runtimeNodes = if (runtime.variants.isEmpty()) emptyList() else buildDependencyGraph( 44 | runtime.getDependenciesForVariant(runtime.variants.first()), 45 | variant, 46 | mutableSetOf(), 47 | type = DependencyType.Runtime, 48 | ) 49 | val compileNodes = if (compile.variants.isEmpty()) emptyList() else buildDependencyGraph( 50 | compile.getDependenciesForVariant(compile.variants.first()), 51 | variant, 52 | mutableSetOf(), 53 | type = DependencyType.Compile, 54 | ) 55 | 56 | runtimeNodes + compileNodes 57 | } 58 | } 59 | 60 | @get:OutputFile 61 | abstract val partialLockFile: RegularFileProperty 62 | 63 | @TaskAction 64 | fun printList() { 65 | val moshi = Moshi.Builder().build() 66 | partialLockFile.asFile.get().writeText(moshi.adapter(NodeList::class.java).toJson(NodeList(graph.get()))) 67 | } 68 | 69 | private val ResolvedDependencyResult.key: String 70 | get() = selected.moduleVersion?.let { info -> "${info.group}:${info.name}" } ?: "" 71 | 72 | private fun buildDependencyGraph(topLevel: List, variant: String, visited: MutableSet, type: DependencyType): List = 73 | topLevel 74 | .asSequence() 75 | .filterIsInstance() 76 | .filter { !it.isConstraint && it.key !in visited } 77 | .onEach { visited += it.key } 78 | .map { 79 | val info = it.selected.moduleVersion!! 80 | Node( 81 | "${info.group}:${info.name}", 82 | Version(info.version), 83 | mutableSetOf(variant), 84 | children = buildDependencyGraph(it.selected.getDependenciesForVariant(it.resolvedVariant), variant, visited, type), 85 | isProjectModule = it.resolvedVariant.owner is ProjectComponentIdentifier, 86 | type = type, 87 | ) 88 | } 89 | .toList() 90 | 91 | data class ResolvedComponentResultPair(val runtime: ResolvedComponentResult, val compile: ResolvedComponentResult) 92 | } 93 | -------------------------------------------------------------------------------- /gradle-plugin/src/main/kotlin/app/cash/better/dynamic/features/tasks/GenerateExternalResourcesTask.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2023 Square, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | package app.cash.better.dynamic.features.tasks 17 | 18 | import org.gradle.api.DefaultTask 19 | import org.gradle.api.file.DirectoryProperty 20 | import org.gradle.api.provider.ListProperty 21 | import org.gradle.api.tasks.Input 22 | import org.gradle.api.tasks.OutputDirectory 23 | import org.gradle.api.tasks.TaskAction 24 | import java.io.File 25 | 26 | abstract class GenerateExternalResourcesTask : DefaultTask() { 27 | @get:OutputDirectory 28 | abstract val outputDirectory: DirectoryProperty 29 | 30 | @get:Input 31 | abstract val declarations: ListProperty 32 | 33 | @TaskAction 34 | fun generateResources() { 35 | val outputFile = File(outputDirectory.asFile.get(), "values/externals.xml") 36 | outputFile.parentFile.mkdirs() 37 | outputFile.writeText( 38 | """ 39 | | 40 | | 41 | | 42 | |${declarations.get().joinToString(separator = "\n") { """