From 7baea2ced84a68667a29c315baccd2142a2727c9 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Thu, 30 Jul 2026 23:56:46 -0700 Subject: [PATCH 1/2] ADFA-4857 | Add project-to-template plugin (convert open project to a .cgt template) Incorporates the standalone templatize-project plugin into the monorepo as project-to-template, conforming to repo conventions and wired into the build and website-deploy pipelines. - Full rename to org.appdevforall.projecttotemplate / "Project to Template". - Uses shared repo-root ../libs/*.jar and ../gradlew (no bundled copies). - Fixes tooltip category to the full plugin.id (plugin_org.appdevforall.projecttotemplate) so the in-IDE tooltip renders real text instead of "n/a". - Sanitizes the user-typed template name before using it as a filesystem path. - Adds Tier 3 offline docs (assets/docs/index.html) + website documentation page. - New icon: solid project card -> arrow -> dashed template card (day/night PNGs + monochrome ic_plugin vector), replacing the 4-square grid glyph. Source SVGs + generator under icon-src/. - Normalizes user-facing text to "Code On The Go". - Adds README Examples row and update-libs.yml website MAP entry. Device-verified on emulator: installs, activates, sidebar + 3-tier tooltip render, and a real conversion of an open project produces a .cgt that installs via IdeTemplateService. --- .github/workflows/update-libs.yml | 1 + README.md | 1 + project-to-template/.gitignore | 15 + project-to-template/README.md | 104 +++ project-to-template/build.gradle.kts | 82 ++ project-to-template/gradle.properties | 5 + project-to-template/icon-src/icon_day.svg | 13 + project-to-template/icon-src/icon_night.svg | 13 + project-to-template/icon-src/make-icon.mjs | 30 + project-to-template/proguard-rules.pro | 1 + .../project-to-template-documentation.html | 459 +++++++++++ project-to-template/settings.gradle.kts | 30 + .../src/main/AndroidManifest.xml | 54 ++ .../src/main/assets/docs/index.html | 137 ++++ .../src/main/assets/icon_day.png | Bin 0 -> 4323 bytes .../src/main/assets/icon_night.png | Bin 0 -> 4304 bytes .../ProjectToTemplatePlugin.kt | 155 ++++ .../projecttotemplate/Templatizer.kt | 758 ++++++++++++++++++ .../fragments/ProjectToTemplateFragment.kt | 227 ++++++ .../src/main/res/drawable/ic_plugin.xml | 22 + .../src/main/res/layout/fragment_main.xml | 113 +++ .../src/main/res/values-night/colors.xml | 7 + .../src/main/res/values/colors.xml | 7 + .../src/main/res/values/strings.xml | 3 + .../src/main/res/values/styles.xml | 9 + 25 files changed, 2246 insertions(+) create mode 100644 project-to-template/.gitignore create mode 100644 project-to-template/README.md create mode 100644 project-to-template/build.gradle.kts create mode 100644 project-to-template/gradle.properties create mode 100644 project-to-template/icon-src/icon_day.svg create mode 100644 project-to-template/icon-src/icon_night.svg create mode 100644 project-to-template/icon-src/make-icon.mjs create mode 100644 project-to-template/proguard-rules.pro create mode 100644 project-to-template/project-to-template-documentation.html create mode 100644 project-to-template/settings.gradle.kts create mode 100644 project-to-template/src/main/AndroidManifest.xml create mode 100644 project-to-template/src/main/assets/docs/index.html create mode 100644 project-to-template/src/main/assets/icon_day.png create mode 100644 project-to-template/src/main/assets/icon_night.png create mode 100644 project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/ProjectToTemplatePlugin.kt create mode 100644 project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/Templatizer.kt create mode 100644 project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/fragments/ProjectToTemplateFragment.kt create mode 100644 project-to-template/src/main/res/drawable/ic_plugin.xml create mode 100644 project-to-template/src/main/res/layout/fragment_main.xml create mode 100644 project-to-template/src/main/res/values-night/colors.xml create mode 100644 project-to-template/src/main/res/values/colors.xml create mode 100644 project-to-template/src/main/res/values/strings.xml create mode 100644 project-to-template/src/main/res/values/styles.xml diff --git a/.github/workflows/update-libs.yml b/.github/workflows/update-libs.yml index 530c1a14..c1e96bec 100644 --- a/.github/workflows/update-libs.yml +++ b/.github/workflows/update-libs.yml @@ -75,6 +75,7 @@ jobs: ["rainbow-on-the-go"]="rainbow-on-the-go.cgp" ["ai-literacy-course"]="ai-literacy-course.cgp" ["flutter-template"]="flutter-template.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 1c143330..bbcd6b71 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`code-suggestions-plugin/`](code-suggestions-plugin/) | Inline ghost-text code completions powered by AI. | | [`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. | +| [`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 00000000..d47336d2 --- /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 00000000..c33167db --- /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 00000000..5a950ec4 --- /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 00000000..21a36623 --- /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 00000000..1afe009c --- /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 00000000..ef0d0bd7 --- /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 00000000..d2c66bc5 --- /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 00000000..fb164d66 --- /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 00000000..d0faba0f --- /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 00000000..8ea95ada --- /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 00000000..2138ac48 --- /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 00000000..4caaf7ce --- /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 0000000000000000000000000000000000000000..f0039e0706673bf478d14765f8d98e088a979b96 GIT binary patch literal 4323 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 Cf%k0y literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a301afc9cd4f0a192af1b4cb11c0d45fc87f7af1 GIT binary patch literal 4304 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 literal 0 HcmV?d00001 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 00000000..1e29dcb2 --- /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 00000000..abb3e3be --- /dev/null +++ b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/Templatizer.kt @@ -0,0 +1,758 @@ +package org.appdevforall.projecttotemplate + +import android.util.Base64 +import org.json.JSONObject +import java.io.File +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. Pure file-I/O logic with no dependency on the plugin API, so it + * can be unit-tested and called 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" +) + +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 + } + report.ok("$path: replaced $n AGP version occurrence(s)") + 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 + } + report.ok("$path: made $total substitution(s)") + 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 + } + report.ok("$path: made $total substitution(s)") + 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 fun cleanupKeystores(projectDir: File, report: Report, dryRun: Boolean) { + val patterns = listOf("jks", "keystore", "p12") + projectDir.walkTopDown() + .filter { it.isFile && it.extension in patterns } + .forEach { + report.remove("$it") + if (!dryRun) it.delete() + } +} + +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) + flagPersonalInfoFiles(projectDir, report) + } + + return languages +} + +private fun isIgnoredUnderRoot(file: File, root: File): Boolean { + var f: File? = file + while (f != null && f != root) { + if (f.name in COPY_IGNORE) return true + f = f.parentFile + } + return false +} + +private fun copyProject(source: File, dest: File) { + source.walkTopDown() + .onEnter { it == source || it.name !in COPY_IGNORE } + .filter { it != source && !isIgnoredUnderRoot(it, source) } + .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) + + 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) + 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() + } + } + + if (dest.exists()) { + report.remove("$dest") + dest.deleteRecursively() + } + outputDir.mkdirs() + copyProject(projectDir, dest) + 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.decode(THUMB_PNG_B64, Base64.DEFAULT)) + 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 00000000..323adeba --- /dev/null +++ b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/fragments/ProjectToTemplateFragment.kt @@ -0,0 +1,227 @@ +package org.appdevforall.projecttotemplate.fragments + +import android.graphics.Color +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.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 ?: "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("No project is currently open.", isError = true) + return + } + val templateName = templateNameInput?.text?.toString()?.trim().orEmpty() + if (templateName.isEmpty()) { + showStatus("Enter a template name to write into template.json.", 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("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" + ) + } + } + + private fun appendLogLine(line: String) { + activity?.runOnUiThread { + val current = logText?.text + logText?.text = if (current.isNullOrEmpty()) line else "$current\n$line" + } + } + + private fun showStatus(message: String, isError: Boolean) { + statusText?.apply { + text = message + setTextColor( + if (isError) Color.parseColor("#B3261E") else Color.parseColor("#0D6B42") + ) + 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 00000000..e6562abb --- /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 00000000..51e9c91b --- /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 00000000..2fe5485a --- /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 00000000..46e84f12 --- /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 00000000..63ffdca7 --- /dev/null +++ b/project-to-template/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Project to Template + 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 00000000..0b4417b4 --- /dev/null +++ b/project-to-template/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + From 2957e4ee69a4439e5bcac2180cd22fad0956f188 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Fri, 31 Jul 2026 09:36:29 -0700 Subject: [PATCH 2/2] ADFA-4857 | Address PR #56 review feedback Fixes from jatezzz's review: Should-fix (produce visibly wrong output): - Dark-mode status colors: use ContextCompat.getColor(status_*_text) instead of hardcoded light hex, so values-night/ is no longer dead. - Re-run isolation: wipe the whole output dir (not just dest/) before a real run, so a previous template name's tree isn't zipped into the new .cgt. - skipCleanup no longer suppresses the personal-info warning: keystores are flagged (not silently shipped) when cleanup is skipped, and flagPersonalInfoFiles always runs. - local.properties is now in COPY_IGNORE (always machine-specific), so it is never bundled. Worth-fixing: - build/ is skipped at copy time when cleaning up, instead of copied then deleted (hundreds of MB of I/O on device). - Log rendering uses TextView.append() (O(1)/line) instead of rebuilding the whole string per line. Minor: - Install button re-enables after a failed install so it can be retried. - Switch android.util.Base64 -> java.util.Base64 (minSdk 26) and add JVM unit tests for sanitizeTemplateName + a dry-run substitution path. - Move user-facing UI strings into strings.xml. - Remove duplicate per-file [OK] log lines (writePeb already logs). - Drop redundant isIgnoredUnderRoot (onEnter prune + name filter cover it). Verified: testDebugUnitTest + assemblePlugin green; a JVM harness runs the real createTemplateBundle twice (Foo then Bar) and confirms no stale-tree / local.properties / build/ / keystore leakage into the .cgt. --- .../projecttotemplate/Templatizer.kt | 74 ++++++++----- .../fragments/ProjectToTemplateFragment.kt | 23 ++-- .../src/main/res/layout/fragment_main.xml | 16 +-- .../src/main/res/values/strings.xml | 15 +++ .../projecttotemplate/TemplatizerTest.kt | 103 ++++++++++++++++++ 5 files changed, 187 insertions(+), 44 deletions(-) create mode 100644 project-to-template/src/test/java/org/appdevforall/projecttotemplate/TemplatizerTest.kt 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 index abb3e3be..c2b26aba 100644 --- a/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/Templatizer.kt +++ b/project-to-template/src/main/kotlin/org/appdevforall/projecttotemplate/Templatizer.kt @@ -1,8 +1,8 @@ package org.appdevforall.projecttotemplate -import android.util.Base64 import org.json.JSONObject import java.io.File +import java.util.Base64 import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @@ -11,13 +11,15 @@ import java.util.zip.ZipOutputStream * 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. Pure file-I/O logic with no dependency on the plugin API, so it - * can be unit-tested and called directly from the plugin's UI layer. + * 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" + ".git", ".gradle", ".cg", ".idea", ".claude", ".androidide", + "release.properties", "local.properties", ) fun token(name: String): String = "\${{$name}}" @@ -191,7 +193,6 @@ private fun processRootBuildGradle(projectDir: File, report: Report, dryRun: Boo report.skip("$path: no 'apply false version' plugin lines found, no changes made") return } - report.ok("$path: replaced $n AGP version occurrence(s)") writePeb(path, newText, report, dryRun) } @@ -333,7 +334,6 @@ private fun processAppBuildGradle( report.skip("$path: no recognized patterns found, no changes made") return basePackage } - report.ok("$path: made $total substitution(s)") writePeb(path, text, report, dryRun) return basePackage } @@ -416,7 +416,6 @@ private fun processJavaKtSources( report.skip("$path: no substitutions made") continue } - report.ok("$path: made $total substitution(s)") writePeb(path, text, report, dryRun) } } @@ -480,16 +479,29 @@ private fun cleanupBuildDirs(projectDir: File, report: Report, dryRun: Boolean) } } +private val KEYSTORE_EXTENSIONS = setOf("jks", "keystore", "p12") + private fun cleanupKeystores(projectDir: File, report: Report, dryRun: Boolean) { - val patterns = listOf("jks", "keystore", "p12") projectDir.walkTopDown() - .filter { it.isFile && it.extension in patterns } + .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) { @@ -530,25 +542,23 @@ private fun runPipeline( if (!skipCleanup) { cleanupBuildDirs(projectDir, report, dryRun) cleanupKeystores(projectDir, report, dryRun) - flagPersonalInfoFiles(projectDir, report) + } 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 isIgnoredUnderRoot(file: File, root: File): Boolean { - var f: File? = file - while (f != null && f != root) { - if (f.name in COPY_IGNORE) return true - f = f.parentFile - } - return false -} - -private fun copyProject(source: File, dest: File) { +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 COPY_IGNORE } - .filter { it != source && !isIgnoredUnderRoot(it, source) } + .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) @@ -688,12 +698,17 @@ fun createTemplateBundle( 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) + 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")}") @@ -704,12 +719,15 @@ fun createTemplateBundle( } } - if (dest.exists()) { - report.remove("$dest") - dest.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) + copyProject(projectDir, dest, copyIgnore) report.ok("$projectDir -> $dest") val languages = runPipeline(dest, module, dryRun, skipCleanup, report) @@ -731,7 +749,7 @@ fun createTemplateBundle( report.ok("$templateJsonFile") val thumbPng = File(templateDir, "thumb.png") - thumbPng.writeBytes(Base64.decode(THUMB_PNG_B64, Base64.DEFAULT)) + 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") 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 index 323adeba..9f471e4f 100644 --- 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 @@ -1,6 +1,5 @@ package org.appdevforall.projecttotemplate.fragments -import android.graphics.Color import android.os.Bundle import android.text.Editable import android.text.TextWatcher @@ -10,6 +9,7 @@ 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 @@ -96,7 +96,7 @@ class ProjectToTemplateFragment : Fragment() { private fun refreshCurrentProject() { val project = currentProject() - currentProjectText?.text = project?.name ?: "No project open" + currentProjectText?.text = project?.name ?: getString(R.string.no_project_open) updateConvertButtonState() } @@ -108,12 +108,12 @@ class ProjectToTemplateFragment : Fragment() { private fun onConvertClicked() { val project = currentProject() if (project == null) { - showStatus("No project is currently open.", isError = true) + showStatus(getString(R.string.no_project_currently_open), isError = true) return } val templateName = templateNameInput?.text?.toString()?.trim().orEmpty() if (templateName.isEmpty()) { - showStatus("Enter a template name to write into template.json.", isError = true) + showStatus(getString(R.string.enter_template_name), isError = true) return } val dryRun = dryRunCheckbox?.isChecked ?: false @@ -159,7 +159,7 @@ class ProjectToTemplateFragment : Fragment() { } } catch (e: Exception) { Log.e(TAG, "Conversion failed", e) - showStatus("Conversion failed: ${e.message}", isError = true) + showStatus(getString(R.string.conversion_failed, e.message ?: ""), isError = true) } finally { setRunning(false) } @@ -183,13 +183,17 @@ class ProjectToTemplateFragment : Fragment() { 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 { - val current = logText?.text - logText?.text = if (current.isNullOrEmpty()) line else "$current\n$line" + logText?.append("$line\n") } } @@ -197,7 +201,10 @@ class ProjectToTemplateFragment : Fragment() { statusText?.apply { text = message setTextColor( - if (isError) Color.parseColor("#B3261E") else Color.parseColor("#0D6B42") + ContextCompat.getColor( + requireContext(), + if (isError) R.color.status_error_text else R.color.status_success_text, + ) ) visibility = View.VISIBLE } diff --git a/project-to-template/src/main/res/layout/fragment_main.xml b/project-to-template/src/main/res/layout/fragment_main.xml index 51e9c91b..a2a73ed5 100644 --- a/project-to-template/src/main/res/layout/fragment_main.xml +++ b/project-to-template/src/main/res/layout/fragment_main.xml @@ -15,7 +15,7 @@ android:id="@+id/currentProjectLabel" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Project" + android:text="@string/project_label" android:textSize="12sp" android:textColor="?android:attr/textColorSecondary" /> @@ -24,7 +24,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="2dp" - android:text="No project open" + android:text="@string/no_project_open" android:textSize="16sp" android:textStyle="bold" /> @@ -38,7 +38,7 @@ android:id="@+id/templateNameInput" android:layout_width="match_parent" android:layout_height="wrap_content" - android:hint="Template name (written into template.json)" + android:hint="@string/template_name_hint" android:singleLine="true" /> @@ -47,13 +47,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="Dry run (preview only, writes nothing)" /> + android:text="@string/dry_run_label" /> + android:text="@string/skip_cleanup_label" /> + android:text="@string/convert_button" /> diff --git a/project-to-template/src/main/res/values/strings.xml b/project-to-template/src/main/res/values/strings.xml index 63ffdca7..fac65423 100644 --- a/project-to-template/src/main/res/values/strings.xml +++ b/project-to-template/src/main/res/values/strings.xml @@ -1,3 +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/test/java/org/appdevforall/projecttotemplate/TemplatizerTest.kt b/project-to-template/src/test/java/org/appdevforall/projecttotemplate/TemplatizerTest.kt new file mode 100644 index 00000000..4bd3dfa4 --- /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") }, + ) + } +}