diff --git a/.github/workflows/update-libs.yml b/.github/workflows/update-libs.yml index 9941877..a74e043 100644 --- a/.github/workflows/update-libs.yml +++ b/.github/workflows/update-libs.yml @@ -76,6 +76,7 @@ jobs: ["ai-literacy-course"]="ai-literacy-course.cgp" ["flutter-template"]="flutter-template.cgp" ["get-ai-models"]="get-ai-models.cgp" + ["project-to-template"]="project-to-template.cgp" ) for module in "${!MAP[@]}"; do src=$(ls "${module}/build/plugin/"*.cgp 2>/dev/null | head -n1) diff --git a/README.md b/README.md index f87d845..52ba445 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`speech-to-text-plugin/`](speech-to-text-plugin/) | Voice-to-code: converts speech to code with AI generation. | | [`vector-search-plugin/`](vector-search-plugin/) | Semantic code search using embeddings and vector similarity. | | [`get-ai-models/`](get-ai-models/) | Bottom-drawer catalog of curated, fully-open GGUF model files; downloads one to `/sdcard/Download` and verifies its SHA-256. | +| [`project-to-template/`](project-to-template/) | Converts the open Android project into a Code On The Go (.cgt) template and installs it into the IDE's New Project picker. | ## Building a plugin diff --git a/project-to-template/.gitignore b/project-to-template/.gitignore new file mode 100644 index 0000000..d47336d --- /dev/null +++ b/project-to-template/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle/ +/local.properties +.idea/ +.androidide/ +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +release.properties +*.jks +*.keystore +*.p12 diff --git a/project-to-template/README.md b/project-to-template/README.md new file mode 100644 index 0000000..c33167d --- /dev/null +++ b/project-to-template/README.md @@ -0,0 +1,104 @@ +# Project to Template + +A [Code On The Go](https://github.com/appdevforall/CodeOnTheGo) plugin (`.cgp`) +that converts the Android project currently open in the IDE into a Code On The +Go (`.cgt`) template bundle — driven entirely from a tab inside the IDE, with an +option to install the result straight into the New Project template picker. The +original project directory is never modified: the plugin copies it into a new +bundle directory next to it and templatizes the copy. + +This started as a `templatize_project.py` desktop script, was ported to a +standalone Kotlin/Compose Android app, and is now a Code On The Go plugin so it +runs inside the IDE with no separate app to install. + +## UI + +The plugin adds a **Project to Template** item to the IDE's sidebar (under +"tools"), which opens a tab with: + +- **Project** — read-only, always the project currently open in the IDE + (via `IdeProjectService.getCurrentProject()`). If no project is open, + conversion is disabled until one is. +- **Template name** — written into the generated `template.json`'s `name` + field, and used as the template subdirectory / `templates.json` `path` entry. +- **Dry run** — preview the substitutions against a disposable copy without + writing or deleting anything. +- **Skip cleanup** — skip `build/` and keystore removal. + +**Convert to Template** stays disabled until a project is open and a template +name is entered. Tapping it runs the pipeline on a background thread and streams +a live log (`[OK]` / `[SKIP]` / `[REMOVED]` / `[REVIEW]`), followed by a summary +with the output paths. On a real (non-dry-run) conversion, an **Install +Template** button then appears below the log — so you can review the log first — +and tapping it registers the `.cgt` directly with the IDE via +`IdeTemplateService`, so it shows up immediately in the New Project template +picker. + +## What the conversion does + +For each run, it: + +1. Creates a new output directory (`-cgt`, next to the project). +2. Copies the project into a subdirectory named after the template name, + skipping `.git`, `.gradle`, `.cg`, `.idea`, `.claude`, `.androidide`, and + `release.properties`. +3. Templatizes the copy: replaces concrete values (Gradle/AGP/Kotlin versions, + package name, app name, SDK levels, Java compatibility levels) with Pebble + tokens (`${{ TOKEN }}`), saving each modified file with a `.peb` suffix; + removes `build/` directories; deletes keystore files (`*.jks`, `*.keystore`, + `*.p12`); and flags files that may contain machine-specific or personal + information (`local.properties`, `google-services.json`, `key.properties`, + `GoogleService-Info.plist`) for manual review. +4. Writes a `templates.json` at the top of the output directory. +5. Adds a `template/` directory containing `template.json` (the parameter/ + metadata schema) and a placeholder `thumb.png` (replace with a real + thumbnail before shipping). +6. Zips the output directory into a `.cgt` file next to it. +7. If the user opts in, registers that `.cgt` with the IDE via + `IdeTemplateService.registerTemplate(...)`. + +Both Kotlin DSL (`build.gradle.kts`) and Groovy DSL (`build.gradle`) projects +are supported. Assumes the app module is named `app` (falls back to the first +module). + +## Pebble whitespace quirk + +A line/segment that ends with a Pebble token must be followed by at least one +character of whitespace, or the parser eats the next character (often a newline, +quote, semicolon, or dot). The conversion inserts a sacrificial space after +every inserted token — after the closing quote when the token is immediately +followed by one, so the quote itself isn't eaten. + +## Project layout + +- `Templatizer.kt` — the substitution pipeline, pure `java.io.File` logic with + no dependency on the plugin API (easy to reason about and test in isolation), + plus the `.cgt` bundle writer/zipper. +- `ProjectToTemplatePlugin.kt` — the `IPlugin` entry point: registers the + sidebar item and the main editor tab, and provides in-IDE tooltip help. +- `fragments/ProjectToTemplateFragment.kt` — the tab's UI: the input form, + background execution, live log, and the install-template flow via + `IdeTemplateService`. +- `src/main/assets/docs/index.html` — the offline Tier 3 help page. + +This plugin uses the shared repo-root `../libs/plugin-api.jar` + +`../libs/gradle-plugin.jar` and the repo-root `../gradlew` — it does not bundle +its own copies. See the repo `CLAUDE.md` for the monorepo conventions. + +## Building + +From this directory, using the repo-root Gradle wrapper: + +``` +../gradlew assemblePlugin # release .cgp -> build/plugin/project-to-template.cgp +../gradlew assemblePluginDebug # debug .cgp -> build/plugin/project-to-template-debug.cgp +``` + +Install the resulting `.cgp` through Code On The Go's Plugin Manager. A +`local.properties` with `sdk.dir=` is required to build. + +## Next steps + +After running a conversion, inspect the generated `.peb` files in the output +bundle to confirm the substitutions and spacing look right, and replace the +placeholder `thumb.png` before distributing the `.cgt` file. diff --git a/project-to-template/build.gradle.kts b/project-to-template/build.gradle.kts new file mode 100644 index 0000000..5a950ec --- /dev/null +++ b/project-to-template/build.gradle.kts @@ -0,0 +1,82 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "project-to-template" +} + +android { + namespace = "org.appdevforall.projecttotemplate" + compileSdk = 34 + + defaultConfig { + applicationId = "org.appdevforall.projecttotemplate" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + // The plugin-api jar is the canonical contract for plugins. Available + // at compile time; the IDE provides it at runtime. + compileOnly(files("../libs/plugin-api.jar")) + + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("androidx.core:core-ktx:1.13.1") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + testImplementation("junit:junit:4.13.2") +} + +// Disable AAR metadata checks that fail under the plugin-builder pipeline. +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { + enabled = false +} diff --git a/project-to-template/gradle.properties b/project-to-template/gradle.properties new file mode 100644 index 0000000..21a3662 --- /dev/null +++ b/project-to-template/gradle.properties @@ -0,0 +1,5 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2560m -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8 +org.gradle.caching=true diff --git a/project-to-template/icon-src/icon_day.svg b/project-to-template/icon-src/icon_day.svg new file mode 100644 index 0000000..1afe009 --- /dev/null +++ b/project-to-template/icon-src/icon_day.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/project-to-template/icon-src/icon_night.svg b/project-to-template/icon-src/icon_night.svg new file mode 100644 index 0000000..ef0d0bd --- /dev/null +++ b/project-to-template/icon-src/icon_night.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/project-to-template/icon-src/make-icon.mjs b/project-to-template/icon-src/make-icon.mjs new file mode 100644 index 0000000..d2c66bc --- /dev/null +++ b/project-to-template/icon-src/make-icon.mjs @@ -0,0 +1,30 @@ +import { Resvg } from '@resvg/resvg-js'; +import { writeFileSync } from 'node:fs'; + +// "Project to Template": a solid source (project) card, an arrow, and a dashed +// template card. Two separate cards, left-to-right transformation. +function svg({ tile, card, line, outline, arrow }) { + return ` + + + + + + + + + + +`; +} + +const day = svg({ tile: '#E7ECFB', card: '#3E5488', line: '#E7ECFB', outline: '#7A8EC4', arrow: '#3E5488' }); +const night = svg({ tile: '#1E2740', card: '#B1C5FF', line: '#1E2740', outline: '#6E82BC', arrow: '#B1C5FF' }); + +for (const [name, s] of [['icon_day', day], ['icon_night', night]]) { + const png = new Resvg(s, { fitTo: { mode: 'width', value: 192 } }).render().asPng(); + writeFileSync(new URL(`./${name}.png`, import.meta.url), png); + writeFileSync(new URL(`./${name}.svg`, import.meta.url), s); + console.log('wrote', name); +} diff --git a/project-to-template/proguard-rules.pro b/project-to-template/proguard-rules.pro new file mode 100644 index 0000000..fb164d6 --- /dev/null +++ b/project-to-template/proguard-rules.pro @@ -0,0 +1 @@ +# Add project specific ProGuard rules here. diff --git a/project-to-template/project-to-template-documentation.html b/project-to-template/project-to-template-documentation.html new file mode 100644 index 0000000..d0faba0 --- /dev/null +++ b/project-to-template/project-to-template-documentation.html @@ -0,0 +1,459 @@ + + + + + +Project to Template Plugin Documentation + + + + +
+

Project to Template Plugin

+

Convert the open Android project into a reusable Code On The Go template

+
Version 1.0.0 · Author: App Dev For All · Package: org.appdevforall.projecttotemplate
+
+ + + + +
+

1. Executive Overview

+

+ Project to Template converts the Android project currently open in Code On + The Go into a reusable project template — a .cgt bundle + — and can install it directly into the New Project template picker, + entirely from inside the IDE. It adds a Project to Template + item to the sidebar (under tools); tapping it opens a tab that + drives the conversion. +

+

+ The original project is never modified. The plugin copies it into a sibling + <project>-cgt directory and templatizes the copy, + replacing concrete values (Gradle/AGP/Kotlin versions, package name, app + name, SDK levels, Java compatibility levels) with Pebble tokens of the form + ${{ TOKEN }}, then writes the template metadata and zips the + result. +

+

+ The substitution engine (Templatizer.kt) is pure + java.io.File logic with no dependency on the plugin API, so it + is easy to reason about and test in isolation. The plugin layer adds the + sidebar entry, the editor tab UI, and three-tier in-IDE help. It declares + filesystem.read, filesystem.write, and + project.structure, and does not touch the network. +

+
+ + +
+

2. Core Functionality

+ +

The conversion tab

+

+ The tab presents a small form: a read-only Project field + (the project currently open, via IdeProjectService.getCurrentProject()), + a Template name input, Dry run and + Skip cleanup checkboxes, a Convert to Template + button, a progress spinner, a status line, and a monospace live log. The + Convert button stays disabled until a project is open and a template name is + entered. +

+ +

The pipeline

+

Each run performs these steps:

+
    +
  1. Create the output directory <project>-cgt next to the project.
  2. +
  3. Copy the project into a subdirectory named after the template, skipping + .git, .gradle, .cg, + .idea, .claude, .androidide, and + release.properties.
  4. +
  5. Templatize the copy (see the substitution table below), saving each + edited file with a .peb suffix and removing the original.
  6. +
  7. Unless Skip cleanup is set: remove build/ + directories, delete keystores (*.jks, *.keystore, + *.p12), and flag files that may contain machine-specific or + personal information for manual review.
  8. +
  9. Write templates.json and template/template.json, + and a placeholder template/thumb.png.
  10. +
  11. Zip the bundle into <template name>.cgt.
  12. +
+ +

Substitutions

+ + + + + + + + + + +
FileTokenized value(s)
gradle/wrapper/gradle-wrapper.propertiesGRADLE_VERSION
settings.gradle(.kts)rootProject.nameAPP_NAME
root build.gradle(.kts)AGP apply false version → AGP_VERSION
module build.gradle(.kts)AGP_VERSION, KOTLIN_VERSION, PACKAGE_NAME, COMPILE_SDK, MIN_SDK, TARGET_SDK, Java source/target compat, JAVA_TARGET
strings.xmlapp_nameAPP_NAME
*.java / *.ktpackage/importPACKAGE_NAME; source tree flattened under a single PACKAGE_NAME directory
+

+ For mixed Java + Kotlin projects, the Kotlin plugin declaration is wrapped in + a {% if LANGUAGE == 'kotlin' %} conditional so the generated + template can produce either a Java-only or Kotlin project. +

+ +

Dry run

+

+ With Dry run checked, the entire pipeline runs against a + disposable temp copy and reports what it would do — no output + directory, .cgt, or deletions are produced. This is the safe way + to preview substitutions on an unfamiliar project. +

+ +

Install

+

+ After a real (non-dry) conversion, an Install Template + button appears below the log. Tapping it calls + IdeTemplateService.registerTemplate(cgtFile), so the template + appears in the New Project picker immediately. +

+ +

The Pebble whitespace quirk

+

+ A segment ending in a Pebble token must be followed by whitespace, or the + parser consumes the next character (a newline, quote, semicolon, or dot). + The pipeline inserts a sacrificial space after every inserted token — + after the closing quote when the token sits directly against one — so + the following character survives. This is a documented gotcha for + Pebble-based templates in this repository. +

+
+ + +
+

3. Technical Architecture

+ +

File layout

+
    +
  • project-to-template/ +
      +
    • build.gradle.kts — applies the plugin-builder; compileOnly ../libs/plugin-api.jar
    • +
    • settings.gradle.kts — buildscript classpath on ../libs/*.jar
    • +
    • src/main/ +
        +
      • AndroidManifest.xml — plugin identity metadata
      • +
      • assets/ +
          +
        • docs/index.html — Tier 3 offline help page
        • +
        • icon_day.png / icon_night.png — Plugin Manager icons
        • +
        +
      • +
      • kotlin/org/appdevforall/projecttotemplate/ +
          +
        • ProjectToTemplatePlugin.kt — lifecycle, sidebar + tab registration, tooltip wiring
        • +
        • Templatizer.kt — the substitution pipeline and .cgt writer/zipper (no plugin-API dependency)
        • +
        • fragments/ProjectToTemplateFragment.kt — the tab UI, background execution, live log, install flow
        • +
        +
      • +
      • res/ — layout, drawable icon, colors (day/night), styles, strings
      • +
      +
    • +
    +
  • +
+ +

Class overview

+ + + + + + + + + + + + + + + + + + + +
ClassRoleKey interfaces
ProjectToTemplatePluginEntry point. Lifecycle, registers the sidebar item and the editor tab, contributes tooltip entries + the Tier 3 docs path.IPlugin, UIExtension, EditorTabExtension, DocumentationExtension
ProjectToTemplateFragmentThe tab body: form, background coroutine, streaming log, and the install-template flow.extends Fragment
Templatizer (file)The whole substitution/cleanup/bundle pipeline as pure file I/O. Testable in isolation.
+ +

Data flow

+
+ user taps "Convert to Template" + | + v + ProjectToTemplateFragment.onConvertClicked() + | reads IdeProjectService.getCurrentProject() + | Dispatchers.IO + v + createTemplateBundle(projectDir, module, templateName, ...) + ├─ copyProject() copy into <project>-cgt/<name> + ├─ runPipeline() tokenize gradle / sources / strings + ├─ cleanup remove build/, keystores; flag secrets + ├─ write templates.json, template/template.json, thumb.png + └─ zipDirectory() -> <name>.cgt + | + | streams [OK]/[SKIP]/[REMOVED]/[REVIEW] lines to the log + v + "Install Template" button + | + v + IdeTemplateService.registerTemplate(cgtFile)
+ +

Build configuration

+ + + + + + + + + +
SettingValue
Gradle plugincom.itsaky.androidide.plugins.build
Compile / Target SDK34
Min SDK26
Java / Kotlin target17
ProGuardDisabled (isMinifyEnabled = false)
Plugin APIcompileOnly via ../libs/plugin-api.jar (shared repo-root jar)
Output format.cgp package
+
+ + +
+

4. Integration Points

+

+ The plugin touches the host IDE through four extension interfaces and three + host services. +

+ +

4.1 Lifecycle (IPlugin)

+

+ initialize() stores the PluginContext inside a + try/catch so a setup exception can't crash the host; + activate(), deactivate(), and dispose() + log transitions. There is no long-lived per-project state to clean up. +

+ +

4.2 Sidebar + editor tab (UIExtension, EditorTabExtension)

+

+ getSideMenuItems() contributes one NavigationItem + (group tools) whose action selects the plugin's editor tab via + IdeEditorTabService.selectPluginTab(). + getMainEditorTabs() contributes the EditorTabItem + that hosts ProjectToTemplateFragment. Both carry a tooltip so + the in-IDE help is reachable by long-press. +

+ +

4.3 Current project + template registration (host services)

+ + + + + + + +
ServiceUse
IdeProjectServiceResolve the currently open project and its modules.
IdeEditorTabServiceSelect/open the plugin's editor tab from the sidebar.
IdeTemplateServiceRegister the produced .cgt so it appears in the template picker.
+

+ Services are obtained through PluginFragmentHelper.getServiceRegistry(PLUGIN_ID) + from the fragment and through context.services from the plugin + class. +

+ +

4.4 Three-tier docs (DocumentationExtension)

+

+ getTooltipCategory() returns + "plugin_org.appdevforall.projecttotemplate" — the literal + "plugin_" prefix plus the full plugin id, which the host both + registers under and looks up by. getTooltipEntries() returns a + single entry (Tier 1 summary + Tier 2 HTML detail) with a button that opens + the Tier 3 page, and getTier3DocsAssetPath() returns + "docs" so the host indexes and serves everything under + src/main/assets/docs/. +

+ +

4.5 Plugin-scoped LayoutInflater

+

+ ProjectToTemplateFragment.onGetLayoutInflater wraps the inflater + with PluginFragmentHelper.getPluginInflater(PLUGIN_ID, parent), + so R.layout.* resolves against the plugin's own APK rather than + the host's resources. +

+ +

4.6 Permissions

+
<meta-data android:name="plugin.permissions"
+           android:value="filesystem.read,filesystem.write,project.structure" />
+

+ The plugin reads the open project, walks its module structure, and writes the + template bundle next to it. It performs no network access. +

+
+ + +
+

5. Deployment & Usage

+ +

Building

+
cd project-to-template
+../gradlew assemblePlugin
+

+ Produces project-to-template/build/plugin/project-to-template.cgp. + The plugin uses the shared repo-root Gradle wrapper and + ../libs/*.jar — it does not bundle its own copies. A + local.properties with sdk.dir=<Android SDK path> + is required. +

+ +

Installation

+
    +
  1. Open Preferences → Plugin Manager → +.
  2. +
  3. Select the project-to-template.cgp file.
  4. +
  5. The IDE discovers ProjectToTemplatePlugin via the manifest + metadata and activates it.
  6. +
  7. A Project to Template item appears in the sidebar under + tools.
  8. +
+ +

Using it

+
    +
  1. Open the project you want to templatize.
  2. +
  3. Tap Project to Template in the sidebar.
  4. +
  5. Enter a template name; optionally check Dry run first.
  6. +
  7. Tap Convert to Template and read the log.
  8. +
  9. Tap Install Template to register it with the IDE.
  10. +
  11. Long-press the sidebar item or tab for the three-tier help.
  12. +
+ +

Runtime requirements

+ + + + + + +
RequirementValue
Min Android versionAPI 26 (Android 8)
Min IDE version1.0.0
Permissionsfilesystem.read, filesystem.write, project.structure
Network accessNone
+
+ + +
+

6. Key Benefits

+
    +
  • No hand-editing. Turns a working project into a + parameterized template automatically, including the fiddly + package-directory flattening and Pebble whitespace handling.
  • +
  • Non-destructive. Works on a copy; the original project + is never touched. A dry run previews everything with zero writes.
  • +
  • End-to-end inside the IDE. Convert and install without + leaving Code On The Go — the result lands directly in the New + Project picker.
  • +
  • Safety-aware cleanup. Removes build output and + keystores, and flags secret-bearing files (local.properties, + google-services.json, …) for manual review before + the template is shared.
  • +
  • Testable core. The substitution engine is pure file + I/O with no plugin-API coupling.
  • +
+
+ + +
+

7. License

+

+ The plugin's source code is licensed per the surrounding + plugin-examples repository (see LICENSE at the repo + root). +

+
+ +
+ Project to Template Plugin Documentation · Version 1.0.0 · org.appdevforall.projecttotemplate +
+ + + diff --git a/project-to-template/settings.gradle.kts b/project-to-template/settings.gradle.kts new file mode 100644 index 0000000..8ea95ad --- /dev/null +++ b/project-to-template/settings.gradle.kts @@ -0,0 +1,30 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath(files("../libs/plugin-api.jar")) + classpath(files("../libs/gradle-plugin.jar")) + classpath("com.android.tools.build:gradle:8.11.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "project-to-template" diff --git a/project-to-template/src/main/AndroidManifest.xml b/project-to-template/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2138ac4 --- /dev/null +++ b/project-to-template/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project-to-template/src/main/assets/docs/index.html b/project-to-template/src/main/assets/docs/index.html new file mode 100644 index 0000000..4caaf7c --- /dev/null +++ b/project-to-template/src/main/assets/docs/index.html @@ -0,0 +1,137 @@ + + + + + + Project to Template — Guide + + + + +

Project to Template

+

Turn the Android project you have open in Code On The Go into a +reusable project template (a .cgt bundle) — and install it straight +into the New Project template picker, all without leaving the IDE.

+ +
+ Your project is never modified. The plugin copies your project into a + new folder next to it (<project>-cgt) and templatizes the + copy. The original stays exactly as it is. +
+ +

What it does

+

A template is a project with the machine-specific and app-specific details +replaced by fill-in-the-blanks tokens. When someone later creates a new project +from your template, Code On The Go asks for values (app name, package name, SDK +levels…) and substitutes them back in. This plugin produces such a template from +a working project automatically, so you don't have to hand-edit anything.

+ +

How to use it

+
    +
  1. Open the project you want to turn into a template in Code On The Go.
  2. +
  3. Tap Project to Template in the sidebar (under tools). A + tab opens.
  4. +
  5. The Project field shows the currently open project (read-only).
  6. +
  7. Type a Template name — this becomes the template's display name.
  8. +
  9. Tap Convert to Template. A live log streams each action; a summary + with the output paths appears when it finishes.
  10. +
  11. Tap Install Template (it appears below the log after a real + conversion) to register the template with the IDE. It then shows up in + the New Project template picker immediately.
  12. +
+ +

Options

+ + + + +
OptionEffect
Dry runPreview every substitution against a throwaway copy. Nothing is written or deleted — useful for checking what would happen first.
Skip cleanupLeave build/ directories and keystore files in place instead of removing them.
+ +

Reading the log

+ + + + + + +
MarkerMeaning
[OK]A file was templatized (saved with a .peb suffix) or written.
[SKIP]A file or pattern wasn't found; nothing to do. Normal for projects that don't use every feature.
[REMOVED]A build/ directory or keystore was deleted from the copy.
[REVIEW]A file that may hold machine-specific or personal data (local.properties, google-services.json, key.properties, GoogleService-Info.plist) — check it by hand before sharing the template.
+ +

What gets replaced with tokens

+

Values are swapped for Pebble tokens of the form ${{ TOKEN }}:

+
    +
  • Gradle version, Android Gradle Plugin version, Kotlin plugin version
  • +
  • Package name (namespace / applicationId), across + build files, package/import statements, and the + source directory tree
  • +
  • App name (rootProject.name and strings.xml's + app_name)
  • +
  • compileSdk, minSdk, targetSdk, and + Java source/target compatibility levels
  • +
+

Both Kotlin DSL (build.gradle.kts) and Groovy DSL +(build.gradle) projects are supported. The plugin assumes the app +module is named app, falling back to the first module it finds.

+ +

What the bundle contains

+
<project>-cgt/
+├── templates.json          list of templates in this bundle
+└── <template name>/
+    ├── … your templatized project (.peb files) …
+    └── template/
+        ├── template.json   parameter & metadata schema
+        └── thumb.png        placeholder thumbnail
+

The whole bundle is also zipped into <template name>.cgt +next to the folder — that single file is what Install Template registers.

+ +
+ Before sharing a template: open the generated .peb files + to confirm the substitutions look right, and replace the placeholder + thumb.png with a real screenshot if you want a custom thumbnail + in the picker. +
+ +

Permissions

+

The plugin declares filesystem.read, +filesystem.write, and project.structure — it reads your +open project, walks its module structure, and writes the template bundle beside +it. It does not access the network.

+ + + diff --git a/project-to-template/src/main/assets/icon_day.png b/project-to-template/src/main/assets/icon_day.png new file mode 100644 index 0000000..f0039e0 Binary files /dev/null and b/project-to-template/src/main/assets/icon_day.png differ diff --git a/project-to-template/src/main/assets/icon_night.png b/project-to-template/src/main/assets/icon_night.png new file mode 100644 index 0000000..a301afc Binary files /dev/null and b/project-to-template/src/main/assets/icon_night.png differ diff --git a/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/ProjectToTemplatePlugin.kt b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/ProjectToTemplatePlugin.kt new file mode 100644 index 0000000..1e29dcb --- /dev/null +++ b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/ProjectToTemplatePlugin.kt @@ -0,0 +1,155 @@ +package org.appdevforall.projecttotemplate + +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.EditorTabExtension +import com.itsaky.androidide.plugins.extensions.EditorTabItem +import com.itsaky.androidide.plugins.extensions.MenuItem +import com.itsaky.androidide.plugins.extensions.NavigationItem +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry +import com.itsaky.androidide.plugins.extensions.TabItem +import com.itsaky.androidide.plugins.extensions.UIExtension +import com.itsaky.androidide.plugins.services.IdeEditorTabService +import androidx.fragment.app.Fragment +import org.appdevforall.projecttotemplate.fragments.ProjectToTemplateFragment + +/** + * Converts the Android project currently open in Code On The Go into a + * Code On The Go (.cgt) template bundle, and optionally installs it directly + * into the IDE via IdeTemplateService. + */ +class ProjectToTemplatePlugin : IPlugin, UIExtension, EditorTabExtension, DocumentationExtension { + + companion object { + private const val TAB_ID = "org_appdevforall_projecttotemplate_main" + private const val TOOLTIP_TAG = "projecttotemplate.overview" + } + + private lateinit var context: PluginContext + + override fun initialize(context: PluginContext): Boolean { + return try { + this.context = context + context.logger.info("ProjectToTemplatePlugin initialized successfully") + true + } catch (e: Exception) { + context.logger.error("ProjectToTemplatePlugin initialization failed", e) + false + } + } + + override fun activate(): Boolean { + context.logger.info("ProjectToTemplatePlugin: Activating plugin") + return true + } + + override fun deactivate(): Boolean { + context.logger.info("ProjectToTemplatePlugin: Deactivating plugin") + return true + } + + override fun dispose() { + context.logger.info("ProjectToTemplatePlugin: Disposing plugin") + } + + // -- UIExtension -- + + override fun getMainMenuItems(): List = emptyList() + + override fun getEditorTabs(): List = emptyList() + + override fun getSideMenuItems(): List { + return listOf( + NavigationItem( + id = "org_appdevforall_projecttotemplate_sidebar", + title = "Project to Template", + icon = R.drawable.ic_plugin, + isEnabled = true, + isVisible = true, + group = "tools", + order = 0, + action = { openPluginTab() }, + tooltipTag = TOOLTIP_TAG, + ) + ) + } + + private fun openPluginTab() { + val editorTabService = context.services.get(IdeEditorTabService::class.java) ?: run { + context.logger.error("Editor tab service not available") + return + } + if (!editorTabService.isTabSystemAvailable()) { + context.logger.error("Editor tab system not available") + return + } + try { + editorTabService.selectPluginTab(TAB_ID) + } catch (e: Exception) { + context.logger.error("Error opening Project to Template tab", e) + } + } + + // -- EditorTabExtension -- + + override fun getMainEditorTabs(): List { + return listOf( + EditorTabItem( + id = TAB_ID, + title = "Project to Template", + icon = R.drawable.ic_plugin, + fragmentFactory = { ProjectToTemplateFragment() }, + isCloseable = true, + isPersistent = false, + order = 0, + isEnabled = true, + isVisible = true, + tooltip = "Convert the open Android project into a Code On The Go template", + ) + ) + } + + override fun onEditorTabSelected(tabId: String, fragment: Fragment) {} + + override fun onEditorTabClosed(tabId: String) {} + + override fun canCloseEditorTab(tabId: String): Boolean = true + + // -- DocumentationExtension -- + + // Must be "plugin_" + the full plugin.id, or the host renders the tooltip as + // the literal string "n/a" at runtime (the build stays green regardless). + override fun getTooltipCategory(): String = "plugin_org.appdevforall.projecttotemplate" + + override fun getTooltipEntries(): List { + return listOf( + PluginTooltipEntry( + tag = TOOLTIP_TAG, + summary = "Project to Template
Convert the project currently open in Code On The Go into a reusable (.cgt) template.", + detail = """ +

Project to Template

+

Enter a template name and tap Convert to Template. The plugin copies the + open project, substitutes concrete values (Gradle/AGP/Kotlin versions, package name, + app name, SDK levels) with Pebble tokens, writes template.json and a + thumbnail, and zips the result into a .cgt file. You can then install it + directly into Code On The Go's New Project template picker.

+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "Open the Project to Template guide", + uri = "index.html", + order = 0, + ) + ), + ) + ) + } + + override fun getTier3DocsAssetPath(): String? = "docs" + + override fun onDocumentationInstall(): Boolean = true + + override fun onDocumentationUninstall() {} +} diff --git a/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/Templatizer.kt b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/Templatizer.kt new file mode 100644 index 0000000..c2b26ab --- /dev/null +++ b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/Templatizer.kt @@ -0,0 +1,776 @@ +package org.appdevforall.projecttotemplate + +import org.json.JSONObject +import java.io.File +import java.util.Base64 +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Turns an Android Studio project into a Code On The Go (.cgt) template + * bundle by substituting concrete values (versions, package name, app name, + * SDK levels, Java compatibility levels) with Pebble tokens (${{ TOKEN }}), + * then writing templates.json and template/template.json alongside a + * thumbnail. No dependency on the plugin API, and the substitution/sanitize + * logic uses only the JDK (java.util.Base64, not android.util.Base64), so it + * is unit-testable on the JVM and callable directly from the plugin's UI layer. + */ + +/** Directory/file names skipped when copying the source project into the bundle. */ +private val COPY_IGNORE = setOf( + ".git", ".gradle", ".cg", ".idea", ".claude", ".androidide", + "release.properties", "local.properties", +) + +fun token(name: String): String = "\${{$name}}" + +class Report(private val onLine: (String) -> Unit = {}) { + val changed = mutableListOf() + val skipped = mutableListOf() + val removed = mutableListOf() + val flagged = mutableListOf() + + fun ok(msg: String) { + changed += msg + onLine("[OK] $msg") + } + + fun skip(msg: String) { + skipped += msg + onLine("[SKIP] $msg") + } + + fun remove(msg: String) { + removed += msg + onLine("[REMOVED] $msg") + } + + fun flag(msg: String) { + flagged += msg + onLine("[REVIEW] $msg") + } +} + +/** + * Replaces the value captured by group 2 of [pattern] with a Pebble token, + * inserting [trailingSpaces] sacrificial spaces after the token (Pebble eats + * the character following a token unless whitespace separates them) - or + * after group 3 instead when [spaceAfterSuffix] is set, e.g. when group 3 is + * a closing quote that must sit directly against the token. [pattern] must + * have exactly three capturing groups: (prefix)(value)(suffix). + */ +private fun subLineEndToken( + text: String, + pattern: String, + tokenName: String, + trailingSpaces: Int = 1, + spaceAfterSuffix: Boolean = false, +): Pair { + val regex = Regex(pattern, RegexOption.MULTILINE) + var count = 0 + val tok = token(tokenName) + val spaces = " ".repeat(trailingSpaces) + val result = regex.replace(text) { m -> + count++ + val prefix = m.groupValues[1] + val suffix = m.groupValues[3] + if (spaceAfterSuffix) "$prefix$tok$suffix$spaces" else "$prefix$tok$spaces$suffix" + } + return result to count +} + +/** Tries each candidate pattern (Kotlin DSL vs. Groovy DSL variants) in turn, applying the first that matches. */ +private fun subFirstMatchToken( + text: String, + patterns: List, + tokenName: String, + trailingSpaces: Int = 1, + spaceAfterSuffix: Boolean = false, +): Pair { + for (pattern in patterns) { + val (newText, n) = subLineEndToken(text, pattern, tokenName, trailingSpaces, spaceAfterSuffix) + if (n > 0) return newText to n + } + return text to 0 +} + +private fun writePeb(path: File, newText: String, report: Report, dryRun: Boolean) { + val pebPath = File(path.parentFile, path.name + ".peb") + report.ok("$path -> ${pebPath.name}") + if (dryRun) return + pebPath.writeText(newText, Charsets.UTF_8) + path.delete() +} + +private fun lineEnding(line: String): String = when { + line.endsWith("\r\n") -> "\r\n" + line.endsWith("\n") -> "\n" + else -> "" +} + +/** Splits text into lines, keeping each line's terminator attached (mirrors Python's splitlines(keepends=True)). */ +private fun splitKeepEnds(text: String): List { + val result = mutableListOf() + var start = 0 + for (i in text.indices) { + if (text[i] == '\n') { + result.add(text.substring(start, i + 1)) + start = i + 1 + } + } + if (start < text.length) result.add(text.substring(start)) + return result +} + +private fun processGradleWrapper(projectDir: File, report: Report, dryRun: Boolean) { + val path = File(projectDir, "gradle/wrapper/gradle-wrapper.properties") + if (!path.exists()) { + report.skip("$path not found") + return + } + val text = path.readText() + val (newText, n) = subLineEndToken( + text, + """(distributionUrl=https\\://services\.gradle\.org/distributions/gradle-)([^-\s]+)(-bin\.zip)""", + "GRADLE_VERSION", + ) + if (n == 0) { + report.skip("$path: distributionUrl pattern not found, no changes made") + return + } + writePeb(path, newText, report, dryRun) +} + +private val ROOT_PROJECT_NAME_REGEX = Regex("""^([ \t]*rootProject\.name[ \t]*=[ \t]*)(["'])([^"']*)\2[ \t]*""") + +private fun processSettingsGradle(projectDir: File, report: Report, dryRun: Boolean) { + var path = File(projectDir, "settings.gradle.kts") + if (!path.exists()) path = File(projectDir, "settings.gradle") + if (!path.exists()) { + report.skip("${File(projectDir, "settings.gradle.kts")} or settings.gradle not found") + return + } + val text = path.readText() + val lines = splitKeepEnds(text) + var n = 0 + val newLines = lines.map { line -> + val m = ROOT_PROJECT_NAME_REGEX.find(line) + if (m != null) { + n++ + val prefix = m.groupValues[1] + val quote = m.groupValues[2] + "$prefix$quote${token("APP_NAME")}$quote ${lineEnding(line)}" + } else { + line + } + } + if (n == 0) { + report.skip("$path: rootProject.name pattern not found, no changes made") + return + } + writePeb(path, newLines.joinToString(""), report, dryRun) +} + +private fun processRootBuildGradle(projectDir: File, report: Report, dryRun: Boolean) { + var path = File(projectDir, "build.gradle.kts") + if (!path.exists()) path = File(projectDir, "build.gradle") + if (!path.exists()) { + report.skip("${File(projectDir, "build.gradle.kts")} or build.gradle not found") + return + } + val text = path.readText() + val (newText, n) = subFirstMatchToken( + text, + listOf( + """(id\("com\.android\.(?:application|library)"\)\s+apply\s+false\s+version\s+")([^"]+)(")""", + """(id\s+'com\.android\.(?:application|library)'\s+apply\s+false\s+version\s+')([^']+)(')""", + """(id\s+"com\.android\.(?:application|library)"\s+apply\s+false\s+version\s+")([^"]+)(")""", + ), + "AGP_VERSION", + spaceAfterSuffix = true, + ) + if (n == 0) { + report.skip("$path: no 'apply false version' plugin lines found, no changes made") + return + } + writePeb(path, newText, report, dryRun) +} + +private val NAMESPACE_PATTERNS = listOf( + """(namespace\s*=\s*")([^"]+)(")""", + """(namespace\s+')([^']+)(')""", + """(namespace\s+")([^"]+)(")""", +) +private val APPLICATION_ID_PATTERNS = listOf( + """(applicationId\s*=\s*")([^"]+)(")""", + """(applicationId\s+')([^']+)(')""", + """(applicationId\s+")([^"]+)(")""", +) + +private val KTS_KOTLIN_PATTERN = Regex( + """^([ \t]*)kotlin\("android"\)\s+version\s+"([^"]+)"[ \t]*\r?\n?""", + RegexOption.MULTILINE, +) +private val GROOVY_KOTLIN_PATTERN = Regex( + """^([ \t]*)id\s+(['"])(?:org\.jetbrains\.kotlin\.android|kotlin-android)\2\s+version\s+\2([^'"]+)\2[ \t]*\r?\n?""", + RegexOption.MULTILINE, +) + +private fun findFirst(text: String, patterns: List): String? { + for (pattern in patterns) { + val m = Regex(pattern, RegexOption.MULTILINE).find(text) + if (m != null) return m.groupValues[2] + } + return null +} + +/** Returns the base package name (from namespace/applicationId) so sources can be re-packaged, or null. */ +private fun processAppBuildGradle( + projectDir: File, + module: String, + report: Report, + dryRun: Boolean, + wrapKotlinInLanguageConditional: Boolean, +): String? { + var path = File(projectDir, "$module/build.gradle.kts") + if (!path.exists()) path = File(projectDir, "$module/build.gradle") + if (!path.exists()) { + report.skip("${File(projectDir, "$module/build.gradle.kts")} or build.gradle not found") + return null + } + var text = path.readText() + var total = 0 + + // Captured before substitution runs below, so it reflects the concrete + // package name even though the build.gradle occurrence gets tokenized. + val basePackage = findFirst(text, NAMESPACE_PATTERNS) ?: findFirst(text, APPLICATION_ID_PATTERNS) + + val r1 = subFirstMatchToken( + text, + listOf( + """(id\("com\.android\.application"\)\s+version\s+")([^"]+)(")""", + """(id\s+'com\.android\.application'\s+version\s+')([^']+)(')""", + """(id\s+"com\.android\.application"\s+version\s+")([^"]+)(")""", + ), + "AGP_VERSION", + spaceAfterSuffix = true, + ) + text = r1.first + total += r1.second + + var m = KTS_KOTLIN_PATTERN.find(text) + var indent: String? = null + var pluginLine: String? = null + if (m != null) { + indent = m.groupValues[1] + pluginLine = "kotlin(\"android\") version \"${token("KOTLIN_VERSION")}\" " + } else { + m = GROOVY_KOTLIN_PATTERN.find(text) + if (m != null) { + indent = m.groupValues[1] + val quote = m.groupValues[2] + pluginLine = "id $quote" + "org.jetbrains.kotlin.android$quote " + + "version $quote${token("KOTLIN_VERSION")}$quote " + } + } + + if (m != null && indent != null && pluginLine != null) { + val replacement = if (wrapKotlinInLanguageConditional) { + "$indent\${% if LANGUAGE == 'kotlin' %} \n$indent$pluginLine\n$indent\${% endif %} \n".also { + report.ok("$path: wrapped Kotlin plugin declaration in LANGUAGE conditional") + } + } else { + "$indent$pluginLine\n".also { + report.ok("$path: templatized Kotlin plugin version (single-language project, no LANGUAGE conditional needed)") + } + } + text = text.substring(0, m.range.first) + replacement + text.substring(m.range.last + 1) + total += 1 + } else { + report.skip("$path: Kotlin plugin line not found (ok for Java-only projects)") + } + + fun apply(patterns: List, tokenName: String, spaceAfterSuffix: Boolean = false) { + val (t, count) = subFirstMatchToken(text, patterns, tokenName, spaceAfterSuffix = spaceAfterSuffix) + text = t + total += count + } + + apply(NAMESPACE_PATTERNS, "PACKAGE_NAME", spaceAfterSuffix = true) + apply( + listOf("""(compileSdk\s*=\s*)(\d+)()""", """(compileSdk(?:Version)?\s+)(\d+)()"""), + "COMPILE_SDK", + ) + apply(APPLICATION_ID_PATTERNS, "PACKAGE_NAME", spaceAfterSuffix = true) + apply( + listOf("""(minSdk\s*=\s*)(\d+)()""", """(minSdk(?:Version)?\s+)(\d+)()"""), + "MIN_SDK", + ) + apply( + listOf("""(targetSdk\s*=\s*)(\d+)()""", """(targetSdk(?:Version)?\s+)(\d+)()"""), + "TARGET_SDK", + ) + apply( + listOf( + """(sourceCompatibility\s*=\s*)(JavaVersion\.\w+|[\d.]+)()""", + """(sourceCompatibility\s+)(JavaVersion\.\w+|[\d.]+)()""", + ), + "JAVA_SOURCE_COMPAT", + ) + apply( + listOf( + """(targetCompatibility\s*=\s*)(JavaVersion\.\w+|[\d.]+)()""", + """(targetCompatibility\s+)(JavaVersion\.\w+|[\d.]+)()""", + ), + "JAVA_TARGET_COMPAT", + ) + apply( + listOf("""(jvmTarget\s*=\s*")([^"]+)(")""", """(jvmTarget\s*=\s*')([^']+)(')"""), + "JAVA_TARGET", + spaceAfterSuffix = true, + ) + + if (total == 0) { + report.skip("$path: no recognized patterns found, no changes made") + return basePackage + } + writePeb(path, text, report, dryRun) + return basePackage +} + +private fun processStringsXml(projectDir: File, module: String, report: Report, dryRun: Boolean) { + val path = File(projectDir, "$module/src/main/res/values/strings.xml") + if (!path.exists()) { + report.skip("$path not found") + return + } + val text = path.readText() + val (newText, n) = subLineEndToken( + text, + """()([^<]*)()""", + "APP_NAME", + ) + if (n == 0) { + report.skip("$path: app_name string not found, no changes made") + return + } + writePeb(path, newText, report, dryRun) +} + +/** + * Replaces every occurrence of the app's base package (as found in the + * module build.gradle) with the PACKAGE_NAME token across all .java and .kt + * files under the module's source tree - both in `package ...` declarations + * and in `import ...` statements referencing the base package or a subpackage. + */ +private fun processJavaKtSources( + projectDir: File, + module: String, + basePackage: String?, + report: Report, + dryRun: Boolean, +) { + if (basePackage.isNullOrEmpty()) { + report.skip( + "${File(projectDir, module)}: no base package found in build.gradle, " + + "skipping .java/.kt package substitution" + ) + return + } + + val srcMain = File(projectDir, "$module/src/main") + val sourceFiles = srcMain.walkTopDown() + .filter { it.isFile && (it.extension == "java" || it.extension == "kt") } + .sortedWith(compareBy({ it.extension }, { it.path })) + .toList() + if (sourceFiles.isEmpty()) { + report.skip("$srcMain: no .java/.kt source files found") + return + } + + val baseEscaped = Regex.escape(basePackage) + + for (path in sourceFiles) { + var text = path.readText() + val isJava = path.extension == "java" + + val packagePattern: String + val importPattern: String + if (isJava) { + packagePattern = """^([ \t]*package[ \t]+)($baseEscaped)((?:\.[\w.]*)?[ \t]*;[ \t]*)$""" + importPattern = """^([ \t]*import[ \t]+)($baseEscaped)((?:\.[\w.*]*)?[ \t]*;[ \t]*)$""" + } else { + packagePattern = """^([ \t]*package[ \t]+)($baseEscaped)((?:\.[\w.]*)?[ \t]*)$""" + importPattern = """^([ \t]*import[ \t]+)($baseEscaped)((?:\.[\w.*]*)?[ \t]*)$""" + } + + val (t1, n1) = subLineEndToken(text, packagePattern, "PACKAGE_NAME") + text = t1 + // No sacrificial space here: unlike other substitutions, imports are + // always followed by more package/class path text, not eaten by Pebble. + val (t2, n2) = subLineEndToken(text, importPattern, "PACKAGE_NAME", trailingSpaces = 0) + text = t2 + val total = n1 + n2 + + if (total == 0) { + report.skip("$path: no substitutions made") + continue + } + writePeb(path, text, report, dryRun) + } +} + +/** + * Moves the base package's directory tree under src/main/java (e.g. + * com/example/app, including nested subpackages) into a single directory + * literally named PACKAGE_NAME, then removes the now-empty parent dirs. + */ +private fun flattenPackageDirectory( + projectDir: File, + module: String, + basePackage: String?, + report: Report, + dryRun: Boolean, +) { + if (basePackage.isNullOrEmpty()) { + report.skip("no base package found, skipping java/ package directory flattening") + return + } + + val javaRoot = File(projectDir, "$module/src/main/java") + if (!javaRoot.isDirectory) { + report.skip("$javaRoot not found") + return + } + + val pkgDir = basePackage.split(".").fold(javaRoot) { acc, part -> File(acc, part) } + if (!pkgDir.isDirectory) { + report.skip("$pkgDir not found, skipping java/ package directory flattening") + return + } + + val targetDir = File(javaRoot, "PACKAGE_NAME") + report.ok("$pkgDir -> $targetDir") + if (dryRun) return + + pkgDir.copyRecursively(targetDir, overwrite = true) + pkgDir.deleteRecursively() + + var parent = pkgDir.parentFile + while (parent != null && parent != javaRoot && parent.isDirectory && parent.listFiles()?.isEmpty() == true) { + val next = parent.parentFile + parent.delete() + parent = next + } +} + +private fun cleanupBuildDirs(projectDir: File, report: Report, dryRun: Boolean) { + val moduleRoots = mutableSetOf() + for (name in listOf("build.gradle.kts", "build.gradle", "build.gradle.kts.peb", "build.gradle.peb")) { + projectDir.walkTopDown().filter { it.isFile && it.name == name }.forEach { it.parentFile?.let(moduleRoots::add) } + } + moduleRoots.add(projectDir) + for (mod in moduleRoots.sortedBy { it.path }) { + val buildDir = File(mod, "build") + if (buildDir.isDirectory) { + report.remove("$buildDir") + if (!dryRun) buildDir.deleteRecursively() + } + } +} + +private val KEYSTORE_EXTENSIONS = setOf("jks", "keystore", "p12") + +private fun cleanupKeystores(projectDir: File, report: Report, dryRun: Boolean) { + projectDir.walkTopDown() + .filter { it.isFile && it.extension in KEYSTORE_EXTENSIONS } + .forEach { + report.remove("$it") + if (!dryRun) it.delete() + } +} + +/** + * When keystore cleanup is skipped, keystores are still copied into the bundle, + * so flag every one loudly instead of silently shipping signing material. + */ +private fun flagKeystores(projectDir: File, report: Report) { + projectDir.walkTopDown() + .filter { it.isFile && it.extension in KEYSTORE_EXTENSIONS } + .forEach { + report.flag("$it is a signing keystore; remove it before distributing this template") + } +} + +private fun flagPersonalInfoFiles(projectDir: File, report: Report) { + val candidates = listOf("local.properties", "google-services.json", "key.properties", "GoogleService-Info.plist") + for (name in candidates) { + projectDir.walkTopDown().filter { it.isFile && it.name == name }.forEach { + report.flag("$it may contain machine-specific or personal information; review/delete manually") + } + } +} + +private fun detectSourceLanguages(projectDir: File, module: String): Set { + val srcMain = File(projectDir, "$module/src/main") + val languages = mutableSetOf() + if (srcMain.walkTopDown().any { it.isFile && it.extension == "java" }) languages += "java" + if (srcMain.walkTopDown().any { it.isFile && it.extension == "kt" }) languages += "kotlin" + return languages +} + +private fun runPipeline( + projectDir: File, + module: String, + dryRun: Boolean, + skipCleanup: Boolean, + report: Report, +): Set { + processGradleWrapper(projectDir, report, dryRun) + processSettingsGradle(projectDir, report, dryRun) + processRootBuildGradle(projectDir, report, dryRun) + + val languages = detectSourceLanguages(projectDir, module) + val isMixedLanguage = languages.size > 1 + + val basePackage = processAppBuildGradle(projectDir, module, report, dryRun, isMixedLanguage) + + processStringsXml(projectDir, module, report, dryRun) + processJavaKtSources(projectDir, module, basePackage, report, dryRun) + flattenPackageDirectory(projectDir, module, basePackage, report, dryRun) + + if (!skipCleanup) { + cleanupBuildDirs(projectDir, report, dryRun) + cleanupKeystores(projectDir, report, dryRun) + } else { + // Cleanup is skipped, so keystores are shipped as-is: flag them so the + // warning is loudest in exactly the run where it matters most. + flagKeystores(projectDir, report) + } + // Always warn about machine-specific / personal files, cleanup or not. + flagPersonalInfoFiles(projectDir, report) + + return languages +} + +private fun copyProject(source: File, dest: File, ignore: Set) { + // onEnter prunes descent into any ignored directory (so its whole subtree is + // skipped and never copied); the name filter drops ignored files at any depth. + source.walkTopDown() + .onEnter { it == source || it.name !in ignore } + .filter { it != source && it.name !in ignore } + .forEach { src -> + val relative = src.relativeTo(source) + val target = File(dest, relative.path) + if (src.isDirectory) { + target.mkdirs() + } else { + target.parentFile?.mkdirs() + src.copyTo(target, overwrite = true) + } + } +} + +fun buildTemplateJson(templateName: String, includeLanguage: Boolean): JSONObject { + val optional = JSONObject() + if (includeLanguage) optional.put("language", JSONObject().put("identifier", "LANGUAGE")) + optional.put("minsdk", JSONObject().put("identifier", "MIN_SDK")) + + val required = JSONObject() + .put("appName", JSONObject().put("identifier", "APP_NAME")) + .put("packageName", JSONObject().put("identifier", "PACKAGE_NAME")) + .put("saveLocation", JSONObject().put("identifier", "SAVE_LOCATION")) + + val system = JSONObject() + .put("agpVersion", JSONObject().put("identifier", "AGP_VERSION")) + .put("kotlinVersion", JSONObject().put("identifier", "KOTLIN_VERSION")) + .put("gradleVersion", JSONObject().put("identifier", "GRADLE_VERSION")) + .put("compileSdk", JSONObject().put("identifier", "COMPILE_SDK")) + .put("targetSdk", JSONObject().put("identifier", "TARGET_SDK")) + .put("javaSourceCompat", JSONObject().put("identifier", "JAVA_SOURCE_COMPAT")) + .put("javaTargetCompat", JSONObject().put("identifier", "JAVA_TARGET_COMPAT")) + .put("javaTarget", JSONObject().put("identifier", "JAVA_TARGET")) + + return JSONObject() + .put("name", templateName) + .put("description", "Creates a new $templateName project") + .put("version", "0.1") + .put( + "parameters", + JSONObject().put("required", required).put("optional", optional), + ) + .put("system", system) +} + +// Generic template thumbnail (phone mockup), used as a stand-in until a project-specific one is supplied. +private const val THUMB_PNG_B64 = + "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" + + "AAAASUVORK5CYII=" + +data class ConversionResult( + val outputDir: File, + val templateDir: File, + val cgtFile: File?, + val report: Report, + val languages: Set, + /** The templatized project directory (project files + template/), or null for a dry run. */ + val projectBundleDir: File?, +) + +/** + * Filesystem-safe form of a user-supplied template name. Maps anything outside + * [A-Za-z0-9._-] to '_' and strips leading/trailing dots, so the name can never + * contain a path separator or resolve to "." / ".." and escape the output + * directory. Falls back to "template" if nothing usable remains. + */ +internal fun sanitizeTemplateName(name: String): String = + name.trim() + .replace(Regex("[^A-Za-z0-9._-]"), "_") + .trim('.') + .ifEmpty { "template" } + +/** + * Full pipeline: copies [projectDir] into a new bundle next to it, templatizes + * the copy, writes templates.json / template/template.json / thumb.png, and + * (unless [dryRun]) zips the bundle into a .cgt file. + */ +fun createTemplateBundle( + projectDir: File, + module: String = "app", + templateName: String, + skipCleanup: Boolean = false, + dryRun: Boolean = false, + onLine: (String) -> Unit = {}, +): ConversionResult { + val report = Report(onLine) + // The template name is user-typed; use a path-safe form for every filesystem + // path (bundle subdir, templates.json "path", .cgt filename) so it can't + // escape outputDir. The original name is kept only for display metadata below. + val safeName = sanitizeTemplateName(templateName) + val outputDir = File(projectDir.parentFile, "${projectDir.name}-cgt") + val dest = File(outputDir, safeName) + + // build/ is regenerated on the consuming side and cleanupBuildDirs would + // only delete it again, so skip copying it entirely unless the user opts to + // keep it (skipCleanup) - saving hundreds of MB of file I/O on a phone. + val copyIgnore = if (skipCleanup) COPY_IGNORE else COPY_IGNORE + "build" + + if (dryRun) { + // Preview against a disposable copy so a dry run never touches the real output path. + val tmpRoot = File.createTempFile("cgt-dryrun", "").apply { delete(); mkdirs() } + val tmpDest = File(tmpRoot, safeName) + try { + copyProject(projectDir, tmpDest, copyIgnore) + val languages = runPipeline(tmpDest, module, dryRun, skipCleanup, report) + report.ok("would write ${File(outputDir, "templates.json")}") + report.ok("would write ${File(dest, "template/template.json")}") + report.ok("would write ${File(dest, "template/thumb.png")}") + return ConversionResult(outputDir, File(dest, "template"), null, report, languages, null) + } finally { + tmpRoot.deleteRecursively() + } + } + + // Wipe the whole output dir, not just dest/: it is reused across runs and + // then zipped wholesale, so a leftover subtree from a previous template name + // would otherwise be bundled into this run's .cgt. + if (outputDir.exists()) { + report.remove("$outputDir") + outputDir.deleteRecursively() + } + outputDir.mkdirs() + copyProject(projectDir, dest, copyIgnore) + report.ok("$projectDir -> $dest") + + val languages = runPipeline(dest, module, dryRun, skipCleanup, report) + val includeLanguage = languages.size > 1 + + val templatesJson = File(outputDir, "templates.json") + val templatesJsonBody = JSONObject().put( + "templates", + org.json.JSONArray().put(JSONObject().put("path", safeName)), + ) + templatesJson.writeText(templatesJsonBody.toString(2) + "\n") + report.ok("$templatesJson") + + val templateDir = File(dest, "template") + templateDir.mkdirs() + + val templateJsonFile = File(templateDir, "template.json") + templateJsonFile.writeText(buildTemplateJson(templateName, includeLanguage).toString(4) + "\n") + report.ok("$templateJsonFile") + + val thumbPng = File(templateDir, "thumb.png") + thumbPng.writeBytes(Base64.getDecoder().decode(THUMB_PNG_B64)) + report.ok("$thumbPng (generic thumbnail, replace with an app-specific one if desired)") + + val cgtFile = File(outputDir.parentFile, "$safeName.cgt") + zipDirectory(outputDir, cgtFile) + report.ok("$cgtFile") + + return ConversionResult(outputDir, templateDir, cgtFile, report, languages, dest) +} + +/** Zips the contents of [sourceDir] (not the directory itself) into [destZip], storing only files at max compression. */ +private fun zipDirectory(sourceDir: File, destZip: File) { + if (destZip.exists()) destZip.delete() + ZipOutputStream(destZip.outputStream()).use { zos -> + zos.setLevel(9) + sourceDir.walkTopDown() + .filter { it.isFile } + .forEach { file -> + val entryName = file.relativeTo(sourceDir).path + zos.putNextEntry(ZipEntry(entryName)) + file.inputStream().use { it.copyTo(zos) } + zos.closeEntry() + } + } +} diff --git a/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/fragments/ProjectToTemplateFragment.kt b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/fragments/ProjectToTemplateFragment.kt new file mode 100644 index 0000000..9f471e4 --- /dev/null +++ b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/fragments/ProjectToTemplateFragment.kt @@ -0,0 +1,234 @@ +package org.appdevforall.projecttotemplate.fragments + +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ProgressBar +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import com.google.android.material.button.MaterialButton +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.textfield.TextInputEditText +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.extensions.IProject +import com.itsaky.androidide.plugins.services.IdeProjectService +import com.itsaky.androidide.plugins.services.IdeTemplateService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.appdevforall.projecttotemplate.R +import org.appdevforall.projecttotemplate.createTemplateBundle +import java.io.File + +class ProjectToTemplateFragment : Fragment() { + + companion object { + private const val PLUGIN_ID = "org.appdevforall.projecttotemplate" + private const val TAG = "ProjectToTemplate" + } + + private var currentProjectText: TextView? = null + private var templateNameInput: TextInputEditText? = null + private var dryRunCheckbox: MaterialCheckBox? = null + private var skipCleanupCheckbox: MaterialCheckBox? = null + private var convertButton: MaterialButton? = null + private var progressBar: ProgressBar? = null + private var statusText: TextView? = null + private var logText: TextView? = null + private var installButton: MaterialButton? = null + + private var isRunning = false + private var pendingCgtFile: File? = null + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + return inflater.inflate(R.layout.fragment_main, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + currentProjectText = view.findViewById(R.id.currentProjectText) + templateNameInput = view.findViewById(R.id.templateNameInput) + dryRunCheckbox = view.findViewById(R.id.dryRunCheckbox) + skipCleanupCheckbox = view.findViewById(R.id.skipCleanupCheckbox) + convertButton = view.findViewById(R.id.convertButton) + progressBar = view.findViewById(R.id.progressBar) + statusText = view.findViewById(R.id.statusText) + logText = view.findViewById(R.id.logText) + installButton = view.findViewById(R.id.installButton) + + convertButton?.setOnClickListener { onConvertClicked() } + installButton?.setOnClickListener { onInstallClicked() } + + val watcher = object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) = updateConvertButtonState() + } + templateNameInput?.addTextChangedListener(watcher) + + refreshCurrentProject() + } + + override fun onResume() { + super.onResume() + refreshCurrentProject() + } + + private fun currentProject(): IProject? = + PluginFragmentHelper.getServiceRegistry(PLUGIN_ID) + ?.get(IdeProjectService::class.java) + ?.getCurrentProject() + + private fun refreshCurrentProject() { + val project = currentProject() + currentProjectText?.text = project?.name ?: getString(R.string.no_project_open) + updateConvertButtonState() + } + + private fun updateConvertButtonState() { + val templateName = templateNameInput?.text?.toString()?.trim().orEmpty() + convertButton?.isEnabled = !isRunning && currentProject() != null && templateName.isNotEmpty() + } + + private fun onConvertClicked() { + val project = currentProject() + if (project == null) { + showStatus(getString(R.string.no_project_currently_open), isError = true) + return + } + val templateName = templateNameInput?.text?.toString()?.trim().orEmpty() + if (templateName.isEmpty()) { + showStatus(getString(R.string.enter_template_name), isError = true) + return + } + val dryRun = dryRunCheckbox?.isChecked ?: false + val skipCleanup = skipCleanupCheckbox?.isChecked ?: false + val moduleName = project.getModules().firstOrNull { it.name == "app" }?.name + ?: project.getModules().firstOrNull()?.name + ?: "app" + + setRunning(true) + logText?.text = "" + statusText?.visibility = View.GONE + pendingCgtFile = null + installButton?.visibility = View.GONE + + viewLifecycleOwner.lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + createTemplateBundle( + projectDir = project.rootDir, + module = moduleName, + templateName = templateName, + skipCleanup = skipCleanup, + dryRun = dryRun, + onLine = { line -> + appendLogLine(line) + }, + ) + } + val summary = buildString { + append("Modified ${result.report.changed.size}, ") + append("skipped ${result.report.skipped.size}, ") + append("removed ${result.report.removed.size}, ") + append("${result.report.flagged.size} to review.\n") + append("Bundle: ${result.outputDir}") + if (result.cgtFile != null) append("\n.cgt file: ${result.cgtFile}") + } + showStatus(summary, isError = false) + + if (result.cgtFile != null) { + pendingCgtFile = result.cgtFile + installButton?.isEnabled = true + installButton?.visibility = View.VISIBLE + } + } catch (e: Exception) { + Log.e(TAG, "Conversion failed", e) + showStatus(getString(R.string.conversion_failed, e.message ?: ""), isError = true) + } finally { + setRunning(false) + } + } + } + + private fun onInstallClicked() { + val cgtFile = pendingCgtFile ?: return + installButton?.isEnabled = false + viewLifecycleOwner.lifecycleScope.launch { + val installed = withContext(Dispatchers.IO) { + runCatching { + val templateService = PluginFragmentHelper + .getServiceRegistry(PLUGIN_ID) + ?.get(IdeTemplateService::class.java) + ?: return@runCatching false + templateService.registerTemplate(cgtFile) + }.onFailure { Log.e(TAG, "Install failed", it) }.getOrDefault(false) + } + appendLogLine( + if (installed) "[OK] Installed template: $cgtFile" + else "[SKIP] Could not install template: $cgtFile" + ) + // Leave the button enabled after a failed install so the user can + // retry without re-running the whole conversion. + if (!installed) installButton?.isEnabled = true + } + } + + private fun appendLogLine(line: String) { + // append() upgrades the buffer to EDITABLE and adds in place - O(1) per + // line - instead of rebuilding the whole log string on every line. + activity?.runOnUiThread { + logText?.append("$line\n") + } + } + + private fun showStatus(message: String, isError: Boolean) { + statusText?.apply { + text = message + setTextColor( + ContextCompat.getColor( + requireContext(), + if (isError) R.color.status_error_text else R.color.status_success_text, + ) + ) + visibility = View.VISIBLE + } + } + + private fun setRunning(running: Boolean) { + isRunning = running + progressBar?.visibility = if (running) View.VISIBLE else View.GONE + templateNameInput?.isEnabled = !running + dryRunCheckbox?.isEnabled = !running + skipCleanupCheckbox?.isEnabled = !running + updateConvertButtonState() + } + + override fun onDestroyView() { + super.onDestroyView() + currentProjectText = null + templateNameInput = null + dryRunCheckbox = null + skipCleanupCheckbox = null + convertButton = null + progressBar = null + statusText = null + logText = null + installButton = null + } +} diff --git a/project-to-template/src/main/res/drawable/ic_plugin.xml b/project-to-template/src/main/res/drawable/ic_plugin.xml new file mode 100644 index 0000000..e6562ab --- /dev/null +++ b/project-to-template/src/main/res/drawable/ic_plugin.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/project-to-template/src/main/res/layout/fragment_main.xml b/project-to-template/src/main/res/layout/fragment_main.xml new file mode 100644 index 0000000..a2a73ed --- /dev/null +++ b/project-to-template/src/main/res/layout/fragment_main.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/project-to-template/src/main/res/values-night/colors.xml b/project-to-template/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..2fe5485 --- /dev/null +++ b/project-to-template/src/main/res/values-night/colors.xml @@ -0,0 +1,7 @@ + + + #B1C5FF + #172E60 + #8CD9B0 + #FFB4AB + diff --git a/project-to-template/src/main/res/values/colors.xml b/project-to-template/src/main/res/values/colors.xml new file mode 100644 index 0000000..46e84f1 --- /dev/null +++ b/project-to-template/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + + #485D92 + #FFFFFF + #0D6B42 + #B3261E + diff --git a/project-to-template/src/main/res/values/strings.xml b/project-to-template/src/main/res/values/strings.xml new file mode 100644 index 0000000..fac6542 --- /dev/null +++ b/project-to-template/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + Project to Template + + + Project + No project open + Template name (written into template.json) + Dry run (preview only, writes nothing) + Skip build/ and keystore cleanup + Convert to Template + Log + Install Template + + + No project is currently open. + Enter a template name to write into template.json. + Conversion failed: %1$s + diff --git a/project-to-template/src/main/res/values/styles.xml b/project-to-template/src/main/res/values/styles.xml new file mode 100644 index 0000000..0b4417b --- /dev/null +++ b/project-to-template/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + diff --git a/project-to-template/src/test/java/org/appdevforall/projecttotemplate/TemplatizerTest.kt b/project-to-template/src/test/java/org/appdevforall/projecttotemplate/TemplatizerTest.kt new file mode 100644 index 0000000..4bd3dfa --- /dev/null +++ b/project-to-template/src/test/java/org/appdevforall/projecttotemplate/TemplatizerTest.kt @@ -0,0 +1,103 @@ +package org.appdevforall.projecttotemplate + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * JVM unit tests for the plugin-API-free conversion logic. These exercise only + * the substitution/sanitize paths (and the dry-run pipeline), which touch no + * Android APIs, so they run under `unitTests.isReturnDefaultValues = true` + * without hitting stubbed android.jar classes. + */ +class TemplatizerTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `token wraps the name in Pebble delimiters`() { + assertEquals("\${{APP_NAME}}", token("APP_NAME")) + } + + @Test + fun `sanitizeTemplateName maps disallowed characters to underscore`() { + assertEquals("My_Template_", sanitizeTemplateName(" My Template! ")) + } + + @Test + fun `sanitizeTemplateName strips path traversal so the name can never escape the output dir`() { + val safe = sanitizeTemplateName("../../etc/passwd") + assertFalse(safe.contains("/")) + assertFalse(safe.contains(File.separator)) + assertFalse(safe.startsWith(".")) + assertFalse(safe == ".." || safe == ".") + } + + @Test + fun `sanitizeTemplateName falls back to template when nothing usable remains`() { + assertEquals("template", sanitizeTemplateName(" ")) + assertEquals("template", sanitizeTemplateName("...")) + } + + @Test + fun `dry run reports the expected substitutions without writing output`() { + val project = tmp.newFolder("MyApp") + File(project, "settings.gradle.kts").writeText( + """rootProject.name = "MyApp"${"\n"}""" + ) + val app = File(project, "app").apply { mkdirs() } + File(app, "build.gradle.kts").writeText( + """ + plugins { + id("com.android.application") + kotlin("android") version "2.0.0" + } + android { + namespace = "com.example.myapp" + compileSdk = 34 + defaultConfig { + applicationId = "com.example.myapp" + minSdk = 26 + targetSdk = 34 + } + } + """.trimIndent() + ) + val res = File(app, "src/main/res/values").apply { mkdirs() } + File(res, "strings.xml").writeText( + """MyApp${"\n"}""" + ) + + val result = createTemplateBundle( + projectDir = project, + module = "app", + templateName = "My Template!", + dryRun = true, + ) + + // Dry run produces no .cgt and leaves the real output directory untouched. + assertNull(result.cgtFile) + assertNull(result.projectBundleDir) + assertFalse(File(project.parentFile, "${project.name}-cgt").exists()) + + // The recognized files were tokenized into .peb previews. + assertTrue( + "expected settings.gradle.kts to be templatized: ${result.report.changed}", + result.report.changed.any { it.contains("settings.gradle.kts.peb") }, + ) + assertTrue( + "expected strings.xml to be templatized: ${result.report.changed}", + result.report.changed.any { it.contains("strings.xml.peb") }, + ) + assertTrue( + "expected app/build.gradle.kts to be templatized: ${result.report.changed}", + result.report.changed.any { it.contains("build.gradle.kts.peb") }, + ) + } +}