diff --git a/.claude/skills/plugin-review/SKILL.md b/.claude/skills/plugin-review/SKILL.md index 9413d87e..33a6a8ba 100644 --- a/.claude/skills/plugin-review/SKILL.md +++ b/.claude/skills/plugin-review/SKILL.md @@ -97,7 +97,7 @@ Search the source tree: - May have other sections. If other sections are present, ensure they are relevant and correct - Must have a white background and body text must be black. - Must be written in English -- Must be at the top-level of the plugin. Must have the same name as the plugin with the .html extension +- Must be at the top-level of the plugin folder, named after the **plugin folder** (kebab-case) with a `.html` extension: `template-manager/` → `template-manager.html`. The short folder-name form is preferred; a `-documentation` suffix (`random-xkcd-documentation.html`) is also acceptable. Do **not** name it after the lowercase `pluginBuilder { pluginName }` when that differs from the folder (i.e. not `templatemanagerplugin.html`). #### 6.7 Tooltips and in-app help Code On The Go has a three-tier in-IDE help model: Tier 1 (brief) and Tier 2 (more detail) are tooltips; Tier 3 is a full offline web page reached from a button on the tooltip. Plugins participate through `DocumentationExtension` (all symbols verifiable in `plugin-api.jar`). This is separate from the 6.6 install-decision page — grade them independently. diff --git a/.claude/skills/plugin-review/references/RUBRIC.md b/.claude/skills/plugin-review/references/RUBRIC.md index b18cb5c9..b82a8a26 100644 --- a/.claude/skills/plugin-review/references/RUBRIC.md +++ b/.claude/skills/plugin-review/references/RUBRIC.md @@ -38,7 +38,8 @@ Help must be available *inside* the running IDE, not only in the standalone 6.6 Requirements: -- The plugin implements `DocumentationExtension` and returns its `plugin_` category from `getTooltipCategory()`. +- The plugin implements `DocumentationExtension` and returns **exactly** `"plugin_"` (the full `plugin.id`) from `getTooltipCategory()`. Any other value (short slug, dotless/underscore form) registers entries under a category the lookup never queries, so tooltips render the literal `n/a` at runtime — **Fail**. +- **Manual `showTooltip` calls must pass the category.** If the plugin shows a tooltip on a custom view via `IdeTooltipService`, it must use the 3-arg `showTooltip(anchorView, category, tag)` with `category = "plugin_"`. A bare 2-arg `showTooltip(view, tag)` resolves under the wrong default category and renders `n/a` even though the entry is registered correctly — **Fail** (the entry exists but never displays; only device long-press reveals it). See the CLAUDE.md "In-app help wiring" recipe. - **Every UI element the plugin contributes has a tooltip.** Each `NavigationItem`, `MenuItem`, `TabItem`, FAB/toolbar action, and `EditorTabItem` carries a `tooltipTag` (or `tooltip` for `EditorTabItem`); any custom `View` the plugin shows is wired to the tooltip system. No contributed element may be left without help. - Every `tooltipTag` resolves to a `PluginTooltipEntry` returned from `getTooltipEntries()` — no dangling tags. Each entry provides a Tier 1 `summary` and a Tier 2 `detail`. - **Complete help is available within the app.** The plugin ships a Tier 3 bundle via `getTier3DocsAssetPath()` that comprehensively covers its functionality, and tooltips link to it through `PluginTooltipButton`s. Tier 3 must work offline (served locally); it is not a link out to the public internet. diff --git a/CLAUDE.md b/CLAUDE.md index 776ac2b5..11e90634 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,6 +57,19 @@ A plugin is an Android *application* module (despite installing as a library) wi Available permission strings (declared comma-separated in `plugin.permissions`): `filesystem.read`, `filesystem.write`, `network.access`, `system.commands`, `ide.settings`, `project.structure`. +### In-app help wiring (tooltips + Tier 3, `DocumentationExtension`) + +Every plugin with UI implements `com.itsaky.androidide.plugins.extensions.DocumentationExtension`. This wiring is fixed and foundational — get **all** of it right or the tooltip renders the literal string **`n/a`** at runtime. The build stays green and the manifest looks fine, so **only device long-press testing catches a mistake** (this bit us once). All symbols are in `plugin-api.jar`. + +1. **Category is `"plugin_"` — exactly.** `getTooltipCategory()` MUST return `"plugin_"` + the full `plugin.id` (e.g. `"plugin_org.appdevforall.templatemanagerplugin"`). The host registers your entries under this string **and** derives the same string when resolving a lookup. Any other value — a short slug, a dotless/underscore form — silently mismatches → `n/a`. +2. **Entries.** `getTooltipEntries()` returns `PluginTooltipEntry(tag, summary, detail, buttons)`: `summary` = Tier 1 (one line shown on long-press), `detail` = Tier 2 (HTML behind "See more"). Keep the `tag` in one shared `const val` used by steps 3–4. +3. **Look tooltips up with the 3-arg overload.** Call `IdeTooltipService.showTooltip(anchorView, category, tag)` and pass `category = "plugin_"` explicitly. **Never use the 2-arg `showTooltip(view, tag)`** — it resolves under a different default category and renders `n/a` even when the entry is registered correctly. Param order is `(anchorView, category, tag)`. +4. **Attach tags to UI.** Set `tooltipTag = ` on every contributed `NavigationItem` / `TabItem` / menu item / FAB; `EditorTabItem` instead takes a literal `tooltip = "..."` string. A contributed element with no tooltip fails review clause 6.7. +5. **Tier 3 (offline page).** Override `getTier3DocsAssetPath()` to return an assets subdir name (convention: `"docs"`), ship real HTML at `src/main/assets//index.html` (white background, black text, English), and link it from an entry via `PluginTooltipButton(description, uri = "index.html", order = 0)` — leave `directPath` false (`true` targets the host's shared docs tree, not your bundle). + +Debug a mismatch against the on-device store (`adb root` first): +`sqlite3 /data/data/com.itsaky.androidide/databases/documentation.db "SELECT c.category, t.tag, substr(t.summary,1,40) FROM Tooltips t JOIN TooltipCategories c ON c.id=t.categoryId WHERE c.category LIKE 'plugin_%'"`. If the row is present but the tooltip still shows `n/a`, the bug is the **lookup** (step 1 or 3), not registration. (The unused `ide_tooltip_table` is a red herring — plugin entries live in `Tooltips` + `TooltipCategories`.) + ### Convention: AAR metadata checks are disabled Most plugins end with: diff --git a/docs/process/learnings.md b/docs/process/learnings.md index fecaf693..bd0f7388 100644 --- a/docs/process/learnings.md +++ b/docs/process/learnings.md @@ -18,6 +18,8 @@ Cross-session gotchas, discoveries, and patterns worth not re-deriving. - **On-device install markers hide code changes.** `ai-literacy-course`'s `CourseInstaller` extracts its bundle once and gates it behind `.installed-v`; if the marker exists, extraction *and* `CourseShell.generate()` are skipped. A logic fix (e.g. lesson-item ordering) has zero on-device effect until `INSTALL_VERSION` is bumped — it looks like "the fix didn't work" and costs a device round-trip. Bump the version constant as part of any extraction/generation change. - **`assemblePlugin` silently ships broken `.cgp`s when downloaded assets are missing.** Plugins with a `downloadAssets` task (`ai-literacy-course` → course ZIP + `pdfjs.zip`; `ndk-installer-plugin`) don't fetch those assets during a plain `assemblePlugin`, and there's no build-time warning — the missing asset only surfaces as a runtime failure on device (`Bundled asset not found: pdfjs.zip` → "Could not prepare the course"). Run `./gradlew downloadAssets assemblePlugin` (or `scripts/update-libs.sh`) and `unzip -l` the `.cgp` to confirm assets are present before handing it over. +- **Plugin tooltips render `n/a` when the category/lookup is wrong — build stays green.** `DocumentationExtension` help is registered into `documentation.db` (`Tooltips` + `TooltipCategories`), but a static build/manifest check can't see a category or overload mismatch. Two traps: (1) `getTooltipCategory()` must be exactly `"plugin_"` (full `plugin.id`); (2) manual lookups must use the 3-arg `IdeTooltipService.showTooltip(anchorView, category, tag)` — the 2-arg `showTooltip(view, tag)` resolves under a different default category and shows `n/a` even though the entry is registered. Only a device long-press reveals it. To debug, `adb root` then query `documentation.db`: if the row is present under `plugin_` but the tooltip shows `n/a`, the bug is the lookup, not registration. Full recipe now in `CLAUDE.md` → "In-app help wiring". +- **Plugin Manager icons need `plugin.icon_day`/`_night` → real PNGs, and the Glide cache defeats icon re-verification.** The card icon comes from those two manifest paths (not `android:icon`); ship `src/main/assets/icon_{day,night}.png` (~192px). When re-verifying an icon change under the same plugin id, the Plugin Manager caches via Glide keyed by path with no mtime invalidation — the old icon persists until you `adb shell rm -rf /data/data/com.itsaky.androidide/cache/image_manager_disk_cache` (needs `adb root`) or install on a clean device. ## CoGo project templates (Pebble `.cgt`) diff --git a/docs/process/retrospective.md b/docs/process/retrospective.md index 5a01e036..52baff34 100644 --- a/docs/process/retrospective.md +++ b/docs/process/retrospective.md @@ -119,3 +119,43 @@ | Agent waited to be asked before running plugin-review | CLAUDE.md | Added "Proactively offer `/plugin-review`" paragraph to the "Plugin review skill" section, listing the triggering changes (new plugin import, dep change, API touch, new asset, libs/ update) | | Agent marked builds "verified" without device-level proof | CLAUDE.md | Added new "Verification" section before "Adding a new plugin", stating build success is necessary but never sufficient and device install is the terminal verification step | | Same as above, reinforcement | Memory | `feedback_plugin_verify_on_device.md` created mid-session — per-project memory layer reinforcing the CLAUDE.md rule | + +## 2026-07-24 - Template Manager plugin: review → fix blockers → device verify → icons/naming → PR + +### Time Breakdown +| Started | Phase | 👤 Hands-On | 🤖 Agent | Problems | +|---------|-------|-------------|----------|----------| +| 3:28pm | Initial review (research subagent, build via symlink, security audit, rubric scorecard) | ██ 8m | ██ 11m | | +| 3:39pm | Fix blockers + first device install (`../libs`, manifest, Tier 3, HTML docs; build; emulator install; sidebar + list verified) | ██ 10m | ████ 39m | ⚠ tooltip showed `n/a` | +| 4:18pm | Root-cause tooltip + icons + card + permissions (`documentation.db` → 3-arg `showTooltip`; icons v1→v2 CGT; card title; reinstall; Tier 1/2/3 verified) | ██ 8m | ██ 22m | ⚠ 1 wrong hypothesis; icons redone once | +| 4:40pm | Naming normalization + "Template Manager" rename (reinstall + verify) | █ 3m | █ 12m | | +| 4:52pm | Commit + push + PR #51 | █ 1m | █ 5m | | + +### Metrics +| Metric | Duration | +|--------|----------| +| Total wall-clock | ~1h 32m | +| Hands-on | ~30m (33%) | +| Automated agent time | ~62m (67%) | +| Idle/testing/away | minimal | +| Retro analysis time | ~2 min | + +_Note: the transcript script reported 108 min "hands-on" but over-counted — it billed two skill injections (plugin-review SKILL text; commit-push-pr context) as user typing (~54 min phantom). Real hands-on ≈ 30 min, mostly reading review reports._ + +### Key Observations +- **Device verification, not the build, found the defect.** Build green + manifest correct, yet the tooltip rendered `n/a` — a bug the original code also had. Only a device long-press exposed it. Strongest evidence yet for "build success ≠ verification." +- **First tooltip fix hypothesis was wrong.** Changing `getTooltipCategory` alone didn't work; the fix came from inspecting `documentation.db` (entry was registered; the 2-arg `showTooltip` lookup was at fault). Lesson: go to ground truth sooner instead of reasoning from sibling-plugin comparison. +- **Icons rendered twice** (stacked-cards → "meh" → CGT-file). Partly driven by the later CGT requirement; a quick direction sketch before a full render could have saved a pass. +- **~20 turns of autonomous device driving** (install/uninstall/reinstall/DB queries/tooltip tests) with no input needed. High productive-to-rework ratio. +- **Platform reinstall cost is inherent** (release signature mismatch forces uninstall→restart→clear-cache→reinstall→restart); batching changes to minimize cycles was correct. + +### Feedback +**What worked:** (from user) The tooltip issue should never recur — asked to codify the foundational wiring so it's right the first time. +**What didn't:** Getting the tooltip wiring right required a device round-trip and one wrong hypothesis; it's deterministic tech that shouldn't have been ambiguous. + +### Actions Taken +| Issue | Action Type | Change | +|---|---|---| +| Tooltip wiring got `n/a` and cost a device round-trip; it's foundational and deterministic | CLAUDE.md | Added "### In-app help wiring (tooltips + Tier 3, `DocumentationExtension`)" under Architecture — the exact recipe: category = `plugin_`, always 3-arg `showTooltip(anchorView, category, tag)`, tooltipTag rules, Tier 3 setup, and the `documentation.db` debug query | +| `/plugin-review` couldn't statically catch the 2-arg `showTooltip` / wrong-category trap | Skill (RUBRIC.md 6.7) | Added two Fail-level checks: category must be exactly `plugin_`, and manual `showTooltip` must use the 3-arg category overload (bare 2-arg → `n/a`) | +| Tooltip + icon-cache gotchas would otherwise be re-learned | Docs (learnings.md) | Added the tooltip `n/a` root-cause + debug query and the Glide icon-cache invalidation note to "Plugin build & install gotchas" | diff --git a/template-manager/.gitignore b/template-manager/.gitignore new file mode 100644 index 00000000..cad9999b --- /dev/null +++ b/template-manager/.gitignore @@ -0,0 +1,78 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +/bin/ +/gen/ +/out/ +# Uncomment the following line in case you need and you don't have the release build type files in your app +# Gradle files +.gradle/ +/build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml + +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +#*.jks +#*.keystore + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ + +# Google Services (e.g. APIs or Firebase) +# google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md + +# Version control +vcs.xml + +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ diff --git a/template-manager/README.md b/template-manager/README.md new file mode 100644 index 00000000..6324b1bb --- /dev/null +++ b/template-manager/README.md @@ -0,0 +1,136 @@ +# Template Manager + +A [Code On The Go](https://github.com/appdevforall/CodeOnTheGo) IDE plugin for +managing `.cgt` project/file templates directly on-device — browse installed +templates, install new ones from your Downloads folder, and uninstall or delete them, +all from a single screen inside the IDE. + +## What it does + +The plugin adds a **Template Manager** entry to the IDE's left sidebar (and a +matching editor tab) that shows a card list of every `.cgt` template it can find: + +- **Installed templates** — every `.cgt` registered in the IDE's template store + (`$IDE_HOME/templates`), shown with a green **Installed** status. These are the + templates that appear in the IDE's *New Project* / *New File* wizard. +- **Available templates** — every `.cgt` sitting in `/sdcard/Download`, shown with a + red **Not installed** status, ready to be installed. + +Each card is titled with the `.cgt` file's own name and shows its version and status; a +single-template file also shows its description. A `.cgt` file can bundle more than one +template (the IDE's own `core.cgt` bundles nine); when it does, the card shows a +**"Contains N templates"** indicator instead of a description, and tapping the card (or +its **View templates** menu entry) opens a sub-screen with one card per bundled +template — each with its own **Details** action. + +## Per-card actions + +Each card has an overflow (⋮) menu: + +| Card state | Single-template | Multi-template | +|---|---|---| +| Installed | **Uninstall**, **Details** | **Uninstall**, **View templates** | +| Not installed (in Downloads) | **Install**, **Details**, **Delete** | **Install**, **View templates**, **Delete** | + +- **Install** — registers the template with the IDE and **moves** the file out of + Downloads into the template store (it no longer appears as a Downloads entry). +- **Uninstall** — unregisters the template and **moves** it back to Downloads under its + original filename, where it reappears as *Not installed*. +- **Delete** — permanently removes the `.cgt` from Downloads, after a confirmation + dialog. +- **Details** — shows a single template's metadata (version, description, and any + optional wizard parameters declared under `parameters.optional`); scrollable. +- **View templates** — for a multi-template `.cgt`, opens the per-template card + sub-screen described above; each of those cards has its own **Details**. + +Install / Uninstall / Delete always operate on the whole `.cgt` file, since that's the +unit the IDE registers. + +## Screenshots + + + + + + + + + + +
+ Template list
+ Template list — installed (green) and available (red) cards, with a + "Contains N templates" indicator for multi-template files. +
+ Per-file overflow menu
+ Per-file ⋮ menu — Install / Details / Delete for a Downloads file. +
+ Multi-template sub-screen
+ Opening a multi-template .cgt shows one card per bundled + template, each with its own ⋮ → Details. +
+ Per-template details
+ A template's Details — version, description, and the optional wizard + parameters declared under parameters.optional. +
+ +## Building + +Requires the shared Code On The Go jars at the repo root (`../libs/plugin-api.jar`, +`../libs/gradle-plugin.jar`) and the `com.itsaky.androidide.plugins.build` Gradle +plugin. Build from this folder with the repo-root Gradle wrapper. + +```bash +./gradlew assemblePluginDebug # build/plugin/templatemanagerplugin-debug.cgp +./gradlew assemblePlugin # build/plugin/templatemanagerplugin.cgp (release) +``` + +## Installing + +1. Build the `.cgp` (see above) and copy it to the device (e.g. into `Download/`) from + `build/plugin/`. +2. In Code On The Go, open **Settings → Plugin Manager**. +3. Tap the **+** button, pick the `.cgp` file, and confirm. +4. Restart the IDE when prompted. + +The plugin then appears in the left sidebar. + +> **Upgrading an already-installed copy?** Code On The Go compares the `.cgp`'s +> signing certificate against the installed one and refuses the install if they +> differ, showing *"…was installed from a different build variant. Uninstall it +> before installing this version."* Despite the wording, this is a **signature +> mismatch** — release builds here aren't signed with a stable key, so it triggers on +> essentially every rebuild, as well as when switching between debug and release. +> (Debug rebuilds on the same machine share the debug keystore, so those can be +> replaced in place.) When you hit it, **uninstall** the existing plugin first +> (its ⋮ menu → Uninstall), restart, then install the new `.cgp`. + +## Plugin manifest + +| Field | Value | +|---|---| +| `plugin.id` | `org.appdevforall.templatemanagerplugin` | +| `plugin.main_class` | `org.appdevforall.templatemanagerplugin.TemplateManagerPlugin` | +| `plugin.permissions` | `filesystem.read,filesystem.write` | +| `plugin.min_ide_version` | `26.29` | +| `plugin.max_ide_version` | `26.30` | + +Two permissions are requested, both used on live code paths: `filesystem.read` to list and +parse `.cgt` files in Downloads and the template store, and `filesystem.write` to register / +unregister templates (via `IdeTemplateService`) and move / delete `.cgt` files. No network, +system-command, or native-code access is requested. + +## Project layout + +``` +src/main/ +├── kotlin/org/appdevforall/templatemanagerplugin/ +│ ├── TemplateManagerPlugin.kt # IPlugin + UIExtension + EditorTabExtension + DocumentationExtension +│ ├── fragments/TemplateManagerPluginFragment.kt # the template dashboard UI + install/uninstall/delete logic +│ ├── adapters/CgtFileAdapter.kt # main list adapter + per-file overflow menu +│ ├── adapters/TemplateCardAdapter.kt # per-template cards for the multi-template sub-screen +│ └── models/CgtFileItem.kt # card model + TemplateMetadata +├── res/ # layouts, PluginTheme, day/night colors, drawables +├── assets/ # icon_day.png / icon_night.png (plugin manager icons) +└── AndroidManifest.xml # plugin.* metadata +``` diff --git a/template-manager/build.gradle.kts b/template-manager/build.gradle.kts new file mode 100644 index 00000000..4b0b2905 --- /dev/null +++ b/template-manager/build.gradle.kts @@ -0,0 +1,94 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "templatemanagerplugin" +} + +android { + namespace = "org.appdevforall.templatemanagerplugin" + compileSdk = 36 + + defaultConfig { + applicationId = "org.appdevforall.templatemanagerplugin" + minSdk = 21 + targetSdk = 36 + versionCode = 1 + versionName = "1.0.0" + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + viewBinding = true + } + + 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" + ) + } + } +} + +dependencies { + compileOnly(files("../libs/plugin-api.jar")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlin.stdlib) + + testImplementation(libs.junit) + testImplementation(libs.json) // real org.json for JVM unit tests (android.jar's is stubbed) + + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.androidx.test.espresso.core) +} + +tasks.wrapper { + gradleVersion = "8.14.3" + distributionType = Wrapper.DistributionType.BIN +} + +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { + enabled = false +} + +tasks.withType().configureEach { + kotlinOptions.jvmTarget = "17" +} diff --git a/template-manager/docs/screenshots/file-menu.png b/template-manager/docs/screenshots/file-menu.png new file mode 100644 index 00000000..c1f351d3 Binary files /dev/null and b/template-manager/docs/screenshots/file-menu.png differ diff --git a/template-manager/docs/screenshots/main-list.png b/template-manager/docs/screenshots/main-list.png new file mode 100644 index 00000000..35e317a3 Binary files /dev/null and b/template-manager/docs/screenshots/main-list.png differ diff --git a/template-manager/docs/screenshots/template-details.png b/template-manager/docs/screenshots/template-details.png new file mode 100644 index 00000000..d1a9c770 Binary files /dev/null and b/template-manager/docs/screenshots/template-details.png differ diff --git a/template-manager/docs/screenshots/templates-subscreen.png b/template-manager/docs/screenshots/templates-subscreen.png new file mode 100644 index 00000000..fe739e86 Binary files /dev/null and b/template-manager/docs/screenshots/templates-subscreen.png differ diff --git a/template-manager/gradle.properties b/template-manager/gradle.properties new file mode 100644 index 00000000..21a36623 --- /dev/null +++ b/template-manager/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/template-manager/gradle/libs.versions.toml b/template-manager/gradle/libs.versions.toml new file mode 100644 index 00000000..57361eee --- /dev/null +++ b/template-manager/gradle/libs.versions.toml @@ -0,0 +1,36 @@ +[versions] +# Must match the kotlin-gradle-plugin classpath pinned in settings.gradle.kts. +kotlin = "2.1.0" +coreKtx = "1.13.1" +appcompat = "1.6.1" +material = "1.12.0" +constraintlayout = "2.2.1" +recyclerview = "1.3.2" +fragment = "1.8.8" +lifecycle = "2.6.2" +coroutines = "1.6.4" +junit = "4.13.2" +json = "20240303" +androidxTestExtJunit = "1.1.5" +androidxTestCore = "1.5.0" +androidxTestRunner = "1.5.2" +espresso = "3.5.1" + +[libraries] +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } +androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } + +# Test +junit = { group = "junit", name = "junit", version.ref = "junit" } +json = { group = "org.json", name = "json", version.ref = "json" } +androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" } +androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } +androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } +androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } diff --git a/template-manager/gradle/wrapper/gradle-wrapper.jar b/template-manager/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..a4b76b95 Binary files /dev/null and b/template-manager/gradle/wrapper/gradle-wrapper.jar differ diff --git a/template-manager/gradle/wrapper/gradle-wrapper.properties b/template-manager/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..e2935348 --- /dev/null +++ b/template-manager/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists \ No newline at end of file diff --git a/template-manager/gradlew b/template-manager/gradlew new file mode 100755 index 00000000..f5feea6d --- /dev/null +++ b/template-manager/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/template-manager/settings.gradle.kts b/template-manager/settings.gradle.kts new file mode 100644 index 00000000..201a950e --- /dev/null +++ b/template-manager/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.1.0") + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "TemplateManagerPlugin" \ No newline at end of file diff --git a/template-manager/src/.gitignore b/template-manager/src/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/template-manager/src/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/template-manager/src/androidTest/kotlin/org/appdevforall/templatemanagerplugin/adapters/CgtFileAdapterTest.kt b/template-manager/src/androidTest/kotlin/org/appdevforall/templatemanagerplugin/adapters/CgtFileAdapterTest.kt new file mode 100644 index 00000000..7ac54672 --- /dev/null +++ b/template-manager/src/androidTest/kotlin/org/appdevforall/templatemanagerplugin/adapters/CgtFileAdapterTest.kt @@ -0,0 +1,138 @@ +package org.appdevforall.templatemanagerplugin.adapters + +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.appcompat.view.ContextThemeWrapper +import org.appdevforall.templatemanagerplugin.R +import org.appdevforall.templatemanagerplugin.models.CgtFileItem +import org.appdevforall.templatemanagerplugin.models.TemplateMetadata +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +/** + * UI test that inflates and binds [CgtFileAdapter]'s card layout on-device, verifying the + * rendered text and status. Runs standalone (no COGO host needed) because the adapter only + * depends on androidx + the plugin's own resources. + */ +@RunWith(AndroidJUnit4::class) +class CgtFileAdapterTest { + + private lateinit var parent: ViewGroup + + private fun item( + name: String, + installed: Boolean, + templates: List + ) = CgtFileItem( + file = File("/tmp/$name"), + name = name, + templates = templates, + installed = installed, + unregisterName = name + ) + + @Before + fun setUp() { + // Card layout uses Material3 theme attributes, so inflate under a Material3 theme. + val themed = ContextThemeWrapper( + ApplicationProvider.getApplicationContext(), + com.google.android.material.R.style.Theme_Material3_DayNight + ) + parent = FrameLayout(themed) + } + + private fun bind(item: CgtFileItem): View { + val items = listOf(item) + val adapter = CgtFileAdapter( + items, + onInstall = {}, onUninstall = {}, onDetails = {}, + onDelete = {}, onViewTemplates = {}, onLongPress = {} + ) + val holder = adapter.onCreateViewHolder(parent, 0) + adapter.bindViewHolder(holder, 0) + return holder.itemView + } + + @Test + fun installedSingleTemplate_showsMetadataAndInstalledStatus() { + val view = bind( + item( + "core.cgt", + installed = true, + templates = listOf(TemplateMetadata("Basic Activity", "Creates a new basic activity", "0.1")) + ) + ) + assertEquals("Basic Activity", view.findViewById(R.id.tvTemplateName).text.toString()) + assertEquals("Creates a new basic activity", view.findViewById(R.id.tvTemplateDesc).text.toString()) + assertEquals("core", view.findViewById(R.id.tvFileName).text.toString()) + assertEquals("v0.1", view.findViewById(R.id.tvTemplateVersion).text.toString()) + assertEquals("Installed", view.findViewById(R.id.tvStatus).text.toString()) + assertEquals(View.GONE, view.findViewById(R.id.tvMultiTemplate).visibility) + } + + @Test + fun notInstalled_showsNotInstalledStatus() { + val view = bind( + item( + "widget.cgt", + installed = false, + templates = listOf(TemplateMetadata("Widget", "d", "1.0")) + ) + ) + assertEquals("Not installed", view.findViewById(R.id.tvStatus).text.toString()) + } + + @Test + fun multiTemplate_showsContainsCount() { + val view = bind( + item( + "core.cgt", + installed = true, + templates = listOf( + TemplateMetadata("A", "da", "0.1"), + TemplateMetadata("B", "db", "0.1"), + TemplateMetadata("C", "dc", "0.1") + ) + ) + ) + val multi = view.findViewById(R.id.tvMultiTemplate) + assertEquals(View.VISIBLE, multi.visibility) + assertEquals("Contains 3 templates", multi.text.toString()) + } + + @Test + fun blankVersion_hidesVersionChip() { + val view = bind( + item( + "x.cgt", + installed = false, + templates = listOf(TemplateMetadata("X", "d", "")) + ) + ) + assertEquals(View.GONE, view.findViewById(R.id.tvTemplateVersion).visibility) + } + + @Test + fun longPress_invokesCallbackWithCardView() { + val items = listOf(item("x.cgt", false, listOf(TemplateMetadata("X", "d", "1.0")))) + var pressed: View? = null + val adapter = CgtFileAdapter( + items, + onInstall = {}, onUninstall = {}, onDetails = {}, + onDelete = {}, onViewTemplates = {}, onLongPress = { pressed = it } + ) + val holder = adapter.onCreateViewHolder(parent, 0) + adapter.bindViewHolder(holder, 0) + + assertTrue(holder.itemView.performLongClick()) + assertEquals(holder.itemView, pressed) + } +} diff --git a/template-manager/src/main/AndroidManifest.xml b/template-manager/src/main/AndroidManifest.xml new file mode 100644 index 00000000..4506b092 --- /dev/null +++ b/template-manager/src/main/AndroidManifest.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/template-manager/src/main/assets/docs/index.html b/template-manager/src/main/assets/docs/index.html new file mode 100644 index 00000000..efa0fcf0 --- /dev/null +++ b/template-manager/src/main/assets/docs/index.html @@ -0,0 +1,119 @@ + + + + + + Template Manager — Help + + + + +

Template Manager — Help

+

+ Template Manager is a single screen for managing the project and file templates + (.cgt files) that Code On The Go offers in its New Project / New File + wizard. This page is the plugin's full offline documentation. +

+ +

1. The template list

+

Opening Template Manager shows a card for every .cgt it can find, in two groups:

+
    +
  • Installed (green) — templates already registered with + the IDE. These are the ones that appear in the New Project / New File wizard. They live in the + IDE's template store under the IDE home directory.
  • +
  • Not installed (red) — .cgt files found in your + device's Download/ folder, ready to install.
  • +
+

+ Each card is titled with the .cgt file's own name and shows its version and + status. A single-template file also shows its description; a multi-template file shows a + "Contains N templates" note instead. The list refreshes automatically whenever you return + to the screen. +

+ +

2. Per-card actions

+

Tap a card's overflow (⋮) menu to act on the whole .cgt:

+ + + + + + + +
ActionAvailable onWhat it does
InstallNot installedRegisters the template with the IDE and moves the file out of Downloads into the template store. It then appears in the New Project / New File wizard.
UninstallInstalledUnregisters the template and restores the .cgt back to Downloads under its original name, where it reappears as Not installed.
DeleteNot installedPermanently deletes the .cgt from Downloads. A confirmation dialog is shown first.
DetailsSingle-template filesShows the template's version, description, and any optional wizard parameters.
View templatesMulti-template filesOpens a sub-screen listing each bundled template (see below).
+

+ Install, Uninstall, and Delete always operate on the whole .cgt file, since that is the + unit the IDE registers. +

+ +

3. Multi-template bundles

+

+ A single .cgt can contain more than one template (the IDE's own core.cgt + bundles several). Those cards show a “Contains N templates” note. Tap the card, or choose + View templates, to open a sub-screen with one card per bundled template — each with its own + Details showing that template's version, description, and optional parameters. +

+ +

4. Safe uninstall

+

+ Uninstalling restores a copy of the .cgt to Downloads before it removes the + copy in the template store. If the restore cannot be written (for example, no free space), the + uninstall is aborted and nothing is removed — so you never lose your only copy of a template. +

+ +

5. Permissions & storage

+

Template Manager requests only filesystem permissions:

+
    +
  • filesystem.read — to list and parse .cgt files in Downloads and the + template store.
  • +
  • filesystem.write — to register / unregister templates and move or delete files.
  • +
+

+ It reads and writes only your Download/ folder and the IDE's own template store. It does + not access the network. +

+ +

6. Troubleshooting

+
    +
  • A .cgt isn't showing up. Confirm it is a valid template archive containing at + least one template/template.json entry, and that it is directly in + Download/ (not a subfolder).
  • +
  • “Template service is not available”. The IDE's template service was not ready; + reopen the screen or restart the IDE.
  • +
  • An installed template has no Uninstall that works. Only templates this plugin installed + carry its registration prefix; templates that shipped with the IDE or were added by other means + can be listed but not uninstalled from here.
  • +
+ + + diff --git a/template-manager/src/main/assets/icon_day.png b/template-manager/src/main/assets/icon_day.png new file mode 100644 index 00000000..e217db37 Binary files /dev/null and b/template-manager/src/main/assets/icon_day.png differ diff --git a/template-manager/src/main/assets/icon_night.png b/template-manager/src/main/assets/icon_night.png new file mode 100644 index 00000000..beb36c59 Binary files /dev/null and b/template-manager/src/main/assets/icon_night.png differ diff --git a/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/TemplateManagerPlugin.kt b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/TemplateManagerPlugin.kt new file mode 100644 index 00000000..1874aa69 --- /dev/null +++ b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/TemplateManagerPlugin.kt @@ -0,0 +1,196 @@ +package org.appdevforall.templatemanagerplugin + +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.UIExtension +import com.itsaky.androidide.plugins.extensions.NavigationItem +import com.itsaky.androidide.plugins.extensions.TabItem +import com.itsaky.androidide.plugins.extensions.MenuItem +import com.itsaky.androidide.plugins.services.IdeEditorTabService +import org.appdevforall.templatemanagerplugin.fragments.TemplateManagerPluginFragment +import com.itsaky.androidide.plugins.extensions.EditorTabExtension +import com.itsaky.androidide.plugins.extensions.EditorTabItem +import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import androidx.fragment.app.Fragment + +class TemplateManagerPlugin : IPlugin, UIExtension, EditorTabExtension, DocumentationExtension { + + private companion object { + const val PLUGIN_ID = "org.appdevforall.templatemanagerplugin" + // Shared by the sidebar item, the editor tab, and the registered tooltip entry so a + // long-press / hover anywhere resolves to the same help entry (see getTooltipEntries). + const val TOOLTIP_TAG = "templatemanager.overview" + } + + private lateinit var context: PluginContext + + override fun initialize(context: PluginContext): Boolean { + return try { + this.context = context + context.logger.info("TemplateManagerPlugin initialized successfully") + true + } catch (e: Exception) { + context.logger.error("TemplateManagerPlugin initialization failed", e) + false + } + } + + override fun activate(): Boolean { + context.logger.info("TemplateManagerPlugin: Activating plugin") + return true + } + + override fun deactivate(): Boolean { + context.logger.info("TemplateManagerPlugin: Deactivating plugin") + return true + } + + override fun dispose() { + context.logger.info("TemplateManagerPlugin: Disposing plugin") + } + + override fun getEditorTabs(): List { + return listOf( + TabItem( + id = "org_appdevforall_templatemanagerplugin_tab", + title = "Template Manager", + fragmentFactory = { TemplateManagerPluginFragment() }, + isEnabled = true, + isVisible = true, + order = 0, + tooltipTag = TOOLTIP_TAG + ) + ) + } + + override fun getMainMenuItems(): List { + return emptyList() + } + + override fun getSideMenuItems(): List { + return listOf( + NavigationItem( + id = "org_appdevforall_templatemanagerplugin_sidebar", + title = "Template Manager", + icon = R.drawable.ic_plugin, + isEnabled = true, + isVisible = true, + group = "plugins", + order = 0, + tooltipTag = TOOLTIP_TAG, + action = { + openPluginTab() + } + ) + ) + } + + private fun openPluginTab() { + context.logger.info("Opening TemplateManagerPlugin in main editor tab") + + 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 + } + + val tabId = "org_appdevforall_templatemanagerplugin_main" + try { + if (editorTabService.selectPluginTab(tabId)) { + context.logger.info("Successfully opened TemplateManagerPlugin tab") + } + } catch (e: Exception) { + context.logger.error("Error opening TemplateManagerPlugin tab", e) + } + } + + override fun getMainEditorTabs(): List { + return listOf( + EditorTabItem( + id = "org_appdevforall_templatemanagerplugin_main", + title = "Template Manager", + icon = R.drawable.ic_plugin, + fragmentFactory = { TemplateManagerPluginFragment() }, + isCloseable = true, + isPersistent = false, + order = 0, + isEnabled = true, + isVisible = true, + tooltip = "Browse, install, and manage .cgt project templates" + ) + ) + } + + override fun onEditorTabSelected(tabId: String, fragment: Fragment) {} + + override fun onEditorTabClosed(tabId: String) {} + + override fun canCloseEditorTab(tabId: String): Boolean = true + + // Must be exactly "plugin_": the host registers this plugin's tooltip entries + // under this category, and the 2-arg IdeTooltipService.showTooltip(view, tag) call in the + // fragment derives the same "plugin_" category when looking them up. Any other + // value makes the lookup miss and the tooltip renders "n/a" (caught in on-device testing). + override fun getTooltipCategory(): String = "plugin_$PLUGIN_ID" + + override fun getTooltipEntries(): List { + return listOf( + PluginTooltipEntry( + tag = TOOLTIP_TAG, + summary = "Template Manager
Install, uninstall, and manage Code On The Go " + + "project templates (.cgt files).", + detail = """ +

Template Manager

+

Manage the .cgt project/file templates available to Code On The Go's + New Project / New File wizard, all from one screen.

+ +

What you see

+
    +
  • Installed (green) - templates registered with the IDE.
  • +
  • Not installed (red) - .cgt files found in your Downloads + folder, ready to install.
  • +
+

A .cgt that bundles more than one template shows a "Contains N templates" + note; tap it to see a card per bundled template.

+ +

Actions (per-card ⋮ menu)

+
    +
  1. Install - registers the template and moves the file from Downloads into + the IDE's template store.
  2. +
  3. Uninstall - unregisters the template and moves it back to Downloads.
  4. +
  5. Delete - permanently removes the .cgt from Downloads + (asks for confirmation first).
  6. +
  7. Details - shows the template's version, description, and optional + wizard parameters.
  8. +
+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "Template Manager guide", + uri = "index.html", + order = 0 + ) + ) + ) + ) + } + + // Tier 3 offline help: the host serves everything under src/main/assets/docs/ locally, + // entry point docs/index.html, reachable from the tooltip button above. + override fun getTier3DocsAssetPath(): String = "docs" + + override fun onDocumentationInstall(): Boolean { + context.logger.info("Installing TemplateManagerPlugin documentation") + return true + } + + override fun onDocumentationUninstall() { + context.logger.info("Removing TemplateManagerPlugin documentation") + } +} diff --git a/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/adapters/CgtFileAdapter.kt b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/adapters/CgtFileAdapter.kt new file mode 100644 index 00000000..98cf3b78 --- /dev/null +++ b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/adapters/CgtFileAdapter.kt @@ -0,0 +1,121 @@ +package org.appdevforall.templatemanagerplugin.adapters + +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import org.appdevforall.templatemanagerplugin.R +import org.appdevforall.templatemanagerplugin.databinding.ItemCgtFileBinding +import org.appdevforall.templatemanagerplugin.models.CgtFileItem +import org.appdevforall.templatemanagerplugin.models.displayName +import org.appdevforall.templatemanagerplugin.models.hasMultipleTemplates +import org.appdevforall.templatemanagerplugin.models.primaryTemplate +import org.appdevforall.templatemanagerplugin.models.versionLabel + +class CgtFileAdapter( + private val items: List, + private val onInstall: (CgtFileItem) -> Unit, + private val onUninstall: (CgtFileItem) -> Unit, + private val onDetails: (CgtFileItem) -> Unit, + private val onDelete: (CgtFileItem) -> Unit, + private val onViewTemplates: (CgtFileItem) -> Unit, + private val onLongPress: (View) -> Unit +) : RecyclerView.Adapter() { + + private companion object { + const val MENU_INSTALL = 1 + const val MENU_UNINSTALL = 2 + const val MENU_DETAILS = 3 + const val MENU_DELETE = 4 + const val MENU_VIEW = 5 + } + + inner class FileViewHolder(private val binding: ItemCgtFileBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(item: CgtFileItem) { + val primary = item.primaryTemplate + // The card represents the .cgt FILE, so its title is the file's own name — not the + // first bundled template's name. Tapping a multi-template card reveals every template. + binding.tvTemplateName.text = item.displayName + val versionText = versionLabel(primary.version) + binding.tvTemplateVersion.text = versionText + binding.tvTemplateVersion.visibility = if (versionText.isBlank()) View.GONE else View.VISIBLE + // The old separate filename line is now redundant with the title. + binding.tvFileName.visibility = View.GONE + + // Long-press any card to show the plugin's help tooltip (COGO convention). + binding.root.setOnLongClickListener { anchor -> onLongPress(anchor); true } + + if (item.hasMultipleTemplates) { + // A single template's description would misrepresent a multi-template file. + binding.tvTemplateDesc.visibility = View.GONE + binding.tvMultiTemplate.visibility = View.VISIBLE + binding.tvMultiTemplate.text = "Contains ${item.templates.size} templates" + binding.root.setOnClickListener { onViewTemplates(item) } + } else { + binding.tvTemplateDesc.visibility = View.VISIBLE + binding.tvTemplateDesc.text = primary.description + binding.tvMultiTemplate.visibility = View.GONE + binding.root.setOnClickListener(null) + binding.root.isClickable = false + } + + if (item.installed) { + binding.tvStatus.text = "Installed" + binding.tvStatus.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.status_success_text) + ) + } else { + binding.tvStatus.text = "Not installed" + binding.tvStatus.setTextColor( + ContextCompat.getColor(binding.root.context, R.color.status_error_text) + ) + } + + binding.btnMenu.setOnClickListener { anchor -> + val popup = PopupMenu(anchor.context, anchor, Gravity.END, 0, R.style.PopupMenuStyle) + if (item.installed) { + popup.menu.add(0, MENU_UNINSTALL, 0, "Uninstall") + } else { + popup.menu.add(0, MENU_INSTALL, 0, "Install") + } + if (item.hasMultipleTemplates) { + popup.menu.add(0, MENU_VIEW, 1, "View templates") + } else { + popup.menu.add(0, MENU_DETAILS, 1, "Details") + } + if (!item.installed) { + popup.menu.add(0, MENU_DELETE, 2, "Delete") + } + popup.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + MENU_INSTALL -> onInstall(item) + MENU_UNINSTALL -> onUninstall(item) + MENU_DETAILS -> onDetails(item) + MENU_DELETE -> onDelete(item) + MENU_VIEW -> onViewTemplates(item) + } + true + } + popup.show() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder { + val binding = ItemCgtFileBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return FileViewHolder(binding) + } + + override fun onBindViewHolder(holder: FileViewHolder, position: Int) { + holder.bind(items[position]) + } + + override fun getItemCount(): Int = items.size +} diff --git a/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/adapters/TemplateCardAdapter.kt b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/adapters/TemplateCardAdapter.kt new file mode 100644 index 00000000..76adcfb6 --- /dev/null +++ b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/adapters/TemplateCardAdapter.kt @@ -0,0 +1,58 @@ +package org.appdevforall.templatemanagerplugin.adapters + +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.PopupMenu +import androidx.recyclerview.widget.RecyclerView +import org.appdevforall.templatemanagerplugin.R +import org.appdevforall.templatemanagerplugin.databinding.ItemTemplateBinding +import org.appdevforall.templatemanagerplugin.models.TemplateMetadata +import org.appdevforall.templatemanagerplugin.models.versionLabel + +/** Lists the individual templates bundled inside a single multi-template .cgt file. */ +class TemplateCardAdapter( + private val templates: List, + private val onDetails: (TemplateMetadata) -> Unit +) : RecyclerView.Adapter() { + + private companion object { + const val MENU_DETAILS = 1 + } + + inner class TemplateViewHolder(private val binding: ItemTemplateBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bind(template: TemplateMetadata) { + binding.tvTemplateName.text = template.name.ifBlank { "(unnamed)" } + val versionText = versionLabel(template.version) + binding.tvTemplateVersion.text = versionText + binding.tvTemplateVersion.visibility = if (versionText.isBlank()) View.GONE else View.VISIBLE + binding.tvTemplateDesc.text = template.description + + binding.btnMenu.setOnClickListener { anchor -> + val popup = PopupMenu(anchor.context, anchor, Gravity.END, 0, R.style.PopupMenuStyle) + popup.menu.add(0, MENU_DETAILS, 0, "Details") + popup.setOnMenuItemClickListener { menuItem -> + if (menuItem.itemId == MENU_DETAILS) onDetails(template) + true + } + popup.show() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TemplateViewHolder { + val binding = ItemTemplateBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + return TemplateViewHolder(binding) + } + + override fun onBindViewHolder(holder: TemplateViewHolder, position: Int) { + holder.bind(templates[position]) + } + + override fun getItemCount(): Int = templates.size +} diff --git a/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/fragments/TemplateManagerPluginFragment.kt b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/fragments/TemplateManagerPluginFragment.kt new file mode 100644 index 00000000..c628e7da --- /dev/null +++ b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/fragments/TemplateManagerPluginFragment.kt @@ -0,0 +1,343 @@ +package org.appdevforall.templatemanagerplugin.fragments + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ScrollView +import android.widget.TextView +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.appdevforall.templatemanagerplugin.R +import org.appdevforall.templatemanagerplugin.adapters.CgtFileAdapter +import org.appdevforall.templatemanagerplugin.adapters.TemplateCardAdapter +import org.appdevforall.templatemanagerplugin.models.CgtFileItem +import org.appdevforall.templatemanagerplugin.models.TemplateMetadata +import org.appdevforall.templatemanagerplugin.models.displayName +import org.appdevforall.templatemanagerplugin.models.primaryTemplate +import org.appdevforall.templatemanagerplugin.parsing.CgtTemplateReader +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import com.itsaky.androidide.plugins.services.IdeEnvironmentService +import com.itsaky.androidide.plugins.services.IdeTemplateService +import com.itsaky.androidide.plugins.services.IdeTooltipService +import java.io.File + +class TemplateManagerPluginFragment : Fragment() { + + companion object { + private const val TAG = "TemplateManagerPlugin" + private const val PLUGIN_ID = "org.appdevforall.templatemanagerplugin" + private const val TEMPLATES_SUBDIR = "templates" + // Must match the PluginTooltipEntry.tag and getTooltipCategory() registered in + // TemplateManagerPlugin. The category is "plugin_". + private const val TOOLTIP_CATEGORY = "plugin_$PLUGIN_ID" + private const val TOOLTIP_TAG = "templatemanager.overview" + // Hardcoded on purpose: the host app holds MANAGE_EXTERNAL_STORAGE, and /sdcard is a + // near-universal compatibility symlink to the primary shared storage on Android. This + // deliberately avoids Environment/MediaStore rather than being an oversight. + private val DOWNLOAD_DIR = File("/sdcard/Download") + } + + private var recyclerView: RecyclerView? = null + private var emptyView: TextView? = null + private var templateService: IdeTemplateService? = null + private var environmentService: IdeEnvironmentService? = null + private var tooltipService: IdeTooltipService? = null + private var refreshJob: Job? = null + + private val items = mutableListOf() + private val adapter = CgtFileAdapter( + items, + onInstall = ::installTemplate, + onUninstall = ::uninstallTemplate, + onDetails = ::showDetails, + onDelete = ::confirmDeleteDownloadFile, + onViewTemplates = ::showTemplateList, + onLongPress = ::showHelpTooltip + ) + + 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? { + setupServices() + return inflater.inflate(R.layout.fragment_main, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + recyclerView = view.findViewById(R.id.recyclerView) + emptyView = view.findViewById(R.id.tvEmpty) + + recyclerView?.layoutManager = LinearLayoutManager(requireContext()) + recyclerView?.adapter = adapter + + refreshTemplates() + } + + override fun onResume() { + super.onResume() + refreshTemplates() + } + + /** + * Re-scans the templates directory and /sdcard/Download and rebuilds the card list. + * The directory listing + per-file zip parsing runs on a background dispatcher (a + * Downloads folder can hold many/large .cgt bundles); results are applied on the main + * thread. Any in-flight scan is cancelled first so rapid refreshes don't race. + */ + private fun refreshTemplates() { + refreshJob?.cancel() + refreshJob = viewLifecycleOwner.lifecycleScope.launch { + val scanned = withContext(Dispatchers.IO) { scanTemplates() } + items.clear() + items.addAll(scanned) + adapter.notifyDataSetChanged() + emptyView?.visibility = if (items.isEmpty()) View.VISIBLE else View.GONE + } + } + + /** Blocking file/zip scan of both locations; call off the main thread. */ + private fun scanTemplates(): List { + // The host registers a plugin's templates as "plugin__" + // (IdeTemplateServiceImpl.prefixedName), and unregisterTemplate() re-applies that + // prefix. So the original name is only recoverable for files carrying THIS plugin's + // prefix; the store also holds the IDE's bundled templates and other plugins' + // templates, which don't. + val prefix = "plugin_${PLUGIN_ID}_" + + val installedItems = templatesDirectory() + ?.listFiles { f -> f.isFile && f.name.endsWith(".cgt", ignoreCase = true) } + ?.sortedBy { it.name } + ?.mapNotNull { file -> + val unregisterName = if (file.name.startsWith(prefix)) { + file.name.removePrefix(prefix) + } else { + Log.w( + TAG, + "Installed template '${file.name}' does not carry this plugin's prefix; " + + "it was not registered by this plugin, so uninstall won't apply to it." + ) + file.name + } + runCatching { parseCgtFile(file, installed = true, unregisterName = unregisterName) }.getOrNull() + } + ?: emptyList() + + val downloadItems = DOWNLOAD_DIR + .listFiles { f -> f.isFile && f.name.endsWith(".cgt", ignoreCase = true) } + ?.sortedBy { it.name } + ?.mapNotNull { file -> + runCatching { parseCgtFile(file, installed = false, unregisterName = file.name) }.getOrNull() + } + ?: emptyList() + + return installedItems + downloadItems + } + + private fun templatesDirectory(): File? { + val ideHome = environmentService?.getIdeHomeDirectory() ?: return null + return File(ideHome, TEMPLATES_SUBDIR) + } + + /** + * Parses a .cgt (which may bundle multiple templates) into a card item, or null if it + * contains no template.json. Actual zip/JSON parsing lives in [CgtTemplateReader]. + */ + private fun parseCgtFile(file: File, installed: Boolean, unregisterName: String): CgtFileItem? { + val templates = CgtTemplateReader.readTemplates(file.inputStream()) + if (templates.isEmpty()) return null + return CgtFileItem( + // Display the original filename: the host stores installed templates with a + // "plugin__" prefix, which unregisterName already strips off. + file = file, + name = unregisterName, + templates = templates, + installed = installed, + unregisterName = unregisterName + ) + } + + private fun installTemplate(item: CgtFileItem) { + val service = templateService + if (service == null) { + Toast.makeText(context, "Template service is not available", Toast.LENGTH_SHORT).show() + return + } + val success = service.registerTemplate(item.file) + if (success) { + item.file.delete() + } + service.reloadTemplates() + refreshTemplates() + Toast.makeText( + context, + if (success) "Installed ${item.displayName}" else "Failed to install ${item.displayName}", + Toast.LENGTH_SHORT + ).show() + } + + private fun uninstallTemplate(item: CgtFileItem) { + val service = templateService + if (service == null) { + Toast.makeText(context, "Template service is not available", Toast.LENGTH_SHORT).show() + return + } + + // Restore a copy to Downloads BEFORE unregistering. Unregister deletes the + // template-store copy, so if the restore fails we must not proceed — otherwise + // the user's only copy would be lost. + val restoredFile = File(DOWNLOAD_DIR, item.unregisterName) + val restored = runCatching { item.file.copyTo(restoredFile, overwrite = true) }.isSuccess + if (!restored) { + Toast.makeText( + context, + "Failed to restore ${item.displayName} to Downloads", + Toast.LENGTH_SHORT + ).show() + return + } + + val success = service.unregisterTemplate(item.unregisterName) + if (!success) { + restoredFile.delete() + } + + service.reloadTemplates() + refreshTemplates() + Toast.makeText( + context, + if (success) "Uninstalled ${item.displayName}" else "Failed to uninstall ${item.displayName}", + Toast.LENGTH_SHORT + ).show() + } + + /** Shows the plugin's help tooltip (registered via DocumentationExtension) anchored to a card. */ + private fun showHelpTooltip(anchor: View) { + val service = tooltipService + if (service == null) { + Toast.makeText(context, "Help is not available", Toast.LENGTH_SHORT).show() + return + } + // Use the explicit-category overload. The 2-arg showTooltip(view, tag) resolves under a + // different default category and renders "n/a" even though the entry is registered + // (confirmed by inspecting the on-device documentation.db). + service.showTooltip(anchor, TOOLTIP_CATEGORY, TOOLTIP_TAG) + } + + private fun confirmDeleteDownloadFile(item: CgtFileItem) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Delete ${item.displayName}?") + .setMessage("This permanently deletes the file from Downloads.") + .setPositiveButton("Delete") { _, _ -> deleteDownloadFile(item) } + .setNegativeButton("Cancel", null) + .show() + } + + private fun deleteDownloadFile(item: CgtFileItem) { + val success = item.file.delete() + refreshTemplates() + Toast.makeText( + context, + if (success) "Deleted ${item.displayName}" else "Failed to delete ${item.displayName}", + Toast.LENGTH_SHORT + ).show() + } + + /** File-level details for a single-template .cgt (multi-template files use [showTemplateList]). */ + private fun showDetails(item: CgtFileItem) { + val primary = item.primaryTemplate + val message = buildString { + append("File: ${item.displayName}\n") + append("Status: ${if (item.installed) "Installed" else "Not installed"}\n") + append("Location: ${item.file.absolutePath}\n") + append("Version: ${primary.version}\n\n") + append(primary.description) + if (primary.optionalTags.isNotEmpty()) { + append("\n\nOptional parameters:") + primary.optionalTags.forEach { append("\n • $it") } + } + } + showScrollableDialog(primary.name.ifBlank { item.displayName }, message) + } + + /** Sub-screen: one card per template bundled inside a multi-template .cgt. */ + private fun showTemplateList(item: CgtFileItem) { + // Use the plugin-themed inflater context so item_template.xml resolves plugin + // resources (chip drawable, theme attrs) instead of the host activity's theme. + val pluginContext = layoutInflater.context + val recycler = RecyclerView(pluginContext).apply { + layoutManager = LinearLayoutManager(pluginContext) + adapter = TemplateCardAdapter(item.templates) { template -> showTemplateDetails(template) } + clipToPadding = false + val vertical = (8 * resources.displayMetrics.density).toInt() + setPadding(0, vertical, 0, vertical) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle("Templates in ${item.displayName}") + .setView(recycler) + .setPositiveButton("Close", null) + .show() + } + + /** Details for a single template selected from the [showTemplateList] sub-screen. */ + private fun showTemplateDetails(template: TemplateMetadata) { + val message = buildString { + append("Version: ${template.version}\n\n") + append(template.description) + if (template.optionalTags.isNotEmpty()) { + append("\n\nOptional parameters:") + template.optionalTags.forEach { append("\n • $it") } + } + } + showScrollableDialog(template.name.ifBlank { "(unnamed)" }, message) + } + + private fun showScrollableDialog(title: String, message: String) { + val padding = (24 * resources.displayMetrics.density).toInt() + val textView = TextView(requireContext()).apply { + text = message + setTextIsSelectable(true) + setPadding(padding, padding, padding, 0) + } + val scrollView = ScrollView(requireContext()).apply { + addView(textView) + } + MaterialAlertDialogBuilder(requireContext()) + .setTitle(title) + .setView(scrollView) + .setPositiveButton("Close", null) + .show() + } + + fun setupServices() { + runCatching { + val serviceRegistry = PluginFragmentHelper.getServiceRegistry(PLUGIN_ID) + templateService = serviceRegistry?.get(IdeTemplateService::class.java) + environmentService = serviceRegistry?.get(IdeEnvironmentService::class.java) + tooltipService = serviceRegistry?.get(IdeTooltipService::class.java) + } + } + + override fun onDestroyView() { + super.onDestroyView() + recyclerView?.adapter = null + recyclerView = null + emptyView = null + } +} diff --git a/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/models/CgtFileItem.kt b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/models/CgtFileItem.kt new file mode 100644 index 00000000..46407093 --- /dev/null +++ b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/models/CgtFileItem.kt @@ -0,0 +1,44 @@ +package org.appdevforall.templatemanagerplugin.models + +import java.io.File + +data class TemplateMetadata( + val name: String, + val description: String, + val version: String, + /** Tags declared under parameters.optional in template.json, e.g. "language (LANGUAGE)". */ + val optionalTags: List = emptyList() +) + +data class CgtFileItem( + val file: File, + val name: String, + val templates: List, + val installed: Boolean, + val unregisterName: String +) + +/** The first template's metadata, used to populate the card's title/description/version. */ +val CgtFileItem.primaryTemplate: TemplateMetadata + get() = templates.firstOrNull() ?: TemplateMetadata(name = "", description = "", version = "") + + + +/** True when this .cgt file bundles more than one template. */ +val CgtFileItem.hasMultipleTemplates: Boolean + get() = templates.size > 1 + +/** [CgtFileItem.name] without the redundant ".cgt" extension, for display only. */ +val CgtFileItem.displayName: String + get() = if (name.endsWith(".cgt", ignoreCase = true)) name.dropLast(4) else name + +/** + * Formats a version for the card's version chip, matching the host Plugin Manager: + * a "v" prefix, and versions with more than three dot-segments truncated to the first + * three plus an ellipsis. Blank versions render as an empty string. + */ +fun versionLabel(version: String): String { + if (version.isBlank()) return "" + val segments = version.split('.') + return if (segments.size > 3) "v${segments.take(3).joinToString(".")}..." else "v$version" +} diff --git a/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/parsing/CgtTemplateReader.kt b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/parsing/CgtTemplateReader.kt new file mode 100644 index 00000000..30cc36db --- /dev/null +++ b/template-manager/src/main/kotlin/org/appdevforall/templatemanagerplugin/parsing/CgtTemplateReader.kt @@ -0,0 +1,61 @@ +package org.appdevforall.templatemanagerplugin.parsing + +import org.appdevforall.templatemanagerplugin.models.TemplateMetadata +import org.json.JSONObject +import java.io.InputStream +import java.util.zip.ZipInputStream + +/** + * Pure parser for Code On the Go template (`.cgt`) archives. A `.cgt` is a zip that may + * bundle one or more templates, each described by a `/template/template.json` entry. + * + * Kept free of Android/IDE dependencies so it can be unit-tested directly. + */ +object CgtTemplateReader { + + private const val TEMPLATE_JSON_SUFFIX = "/template/template.json" + + /** + * Reads every `/template/template.json` entry from a `.cgt` zip [input] and returns + * one [TemplateMetadata] per entry (empty if the archive contains none). The stream is + * consumed and closed. + */ + fun readTemplates(input: InputStream): List { + val templates = mutableListOf() + ZipInputStream(input).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (!entry.isDirectory && entry.name.endsWith(TEMPLATE_JSON_SUFFIX)) { + val json = JSONObject(zip.readBytes().toString(Charsets.UTF_8)) + templates.add( + TemplateMetadata( + name = json.optString("name"), + description = json.optString("description"), + version = json.optString("version"), + optionalTags = parseOptionalTags(json) + ) + ) + } + zip.closeEntry() + } + } + return templates + } + + /** + * Collects the tags declared under `parameters.optional`, each rendered as + * " ()" when the entry carries an identifier, else just "". + */ + fun parseOptionalTags(json: JSONObject): List { + val optional = json.optJSONObject("parameters")?.optJSONObject("optional") + ?: return emptyList() + val tags = mutableListOf() + val keys = optional.keys() + while (keys.hasNext()) { + val key = keys.next() + val identifier = optional.optJSONObject(key)?.optString("identifier").orEmpty() + tags.add(if (identifier.isNotBlank()) "$key ($identifier)" else key) + } + return tags + } +} diff --git a/template-manager/src/main/res/drawable/bg_popup_menu.xml b/template-manager/src/main/res/drawable/bg_popup_menu.xml new file mode 100644 index 00000000..cf915baf --- /dev/null +++ b/template-manager/src/main/res/drawable/bg_popup_menu.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/template-manager/src/main/res/drawable/bg_rounded_chip.xml b/template-manager/src/main/res/drawable/bg_rounded_chip.xml new file mode 100644 index 00000000..8ad1b6e0 --- /dev/null +++ b/template-manager/src/main/res/drawable/bg_rounded_chip.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/template-manager/src/main/res/drawable/ic_more_vert.xml b/template-manager/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 00000000..c5fabe99 --- /dev/null +++ b/template-manager/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,11 @@ + + + + diff --git a/template-manager/src/main/res/drawable/ic_plugin.xml b/template-manager/src/main/res/drawable/ic_plugin.xml new file mode 100644 index 00000000..70f6741f --- /dev/null +++ b/template-manager/src/main/res/drawable/ic_plugin.xml @@ -0,0 +1,10 @@ + + + diff --git a/template-manager/src/main/res/layout/fragment_main.xml b/template-manager/src/main/res/layout/fragment_main.xml new file mode 100644 index 00000000..ecbe0873 --- /dev/null +++ b/template-manager/src/main/res/layout/fragment_main.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/template-manager/src/main/res/layout/item_cgt_file.xml b/template-manager/src/main/res/layout/item_cgt_file.xml new file mode 100644 index 00000000..07ee4d7e --- /dev/null +++ b/template-manager/src/main/res/layout/item_cgt_file.xml @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/template-manager/src/main/res/layout/item_template.xml b/template-manager/src/main/res/layout/item_template.xml new file mode 100644 index 00000000..d020a320 --- /dev/null +++ b/template-manager/src/main/res/layout/item_template.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/template-manager/src/main/res/values-night/colors.xml b/template-manager/src/main/res/values-night/colors.xml new file mode 100644 index 00000000..35516a83 --- /dev/null +++ b/template-manager/src/main/res/values-night/colors.xml @@ -0,0 +1,20 @@ + + + #88D6B8 + #00382A + #00513D + #A4F2D3 + #B3CCBF + #1F352C + #191C1A + #E1E3DF + #BFC9C1 + #89938C + #404943 + #E1E3DF + #2E3230 + #88D6B8 + #0D2E22 + #FFB4AB + #690005 + diff --git a/template-manager/src/main/res/values/colors.xml b/template-manager/src/main/res/values/colors.xml new file mode 100644 index 00000000..4f65d131 --- /dev/null +++ b/template-manager/src/main/res/values/colors.xml @@ -0,0 +1,20 @@ + + + #006C4C + #FFFFFF + #89F8C7 + #002114 + #4D6357 + #FFFFFF + #FBFDF9 + #191C1A + #404943 + #707973 + #BFC9C1 + @color/plugin_on_surface + #E7EAE4 + @color/plugin_primary + @color/plugin_primary_container + #BA1A1A + #FFDAD6 + \ No newline at end of file diff --git a/template-manager/src/main/res/values/strings.xml b/template-manager/src/main/res/values/strings.xml new file mode 100644 index 00000000..60845ea5 --- /dev/null +++ b/template-manager/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Template Manager + "Version: " + diff --git a/template-manager/src/main/res/values/styles.xml b/template-manager/src/main/res/values/styles.xml new file mode 100644 index 00000000..84be2f1d --- /dev/null +++ b/template-manager/src/main/res/values/styles.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/template-manager/src/main/res/xml/backup_rules.xml b/template-manager/src/main/res/xml/backup_rules.xml new file mode 100644 index 00000000..4df92558 --- /dev/null +++ b/template-manager/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/template-manager/src/main/res/xml/data_extraction_rules.xml b/template-manager/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 00000000..9ee9997b --- /dev/null +++ b/template-manager/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/template-manager/src/test/kotlin/org/appdevforall/templatemanagerplugin/models/CgtFileItemTest.kt b/template-manager/src/test/kotlin/org/appdevforall/templatemanagerplugin/models/CgtFileItemTest.kt new file mode 100644 index 00000000..a6fa23f4 --- /dev/null +++ b/template-manager/src/test/kotlin/org/appdevforall/templatemanagerplugin/models/CgtFileItemTest.kt @@ -0,0 +1,75 @@ +package org.appdevforall.templatemanagerplugin.models + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CgtFileItemTest { + + private fun item( + name: String, + templates: List = listOf(TemplateMetadata("T", "d", "1.0")) + ) = CgtFileItem( + file = File("/tmp/$name"), + name = name, + templates = templates, + installed = false, + unregisterName = name + ) + + @Test + fun displayName_stripsCgtExtension() { + assertEquals("core", item("core.cgt").displayName) + assertEquals("core", item("core.CGT").displayName) // case-insensitive + } + + @Test + fun displayName_leavesOtherNamesUnchanged() { + assertEquals("core", item("core").displayName) + assertEquals("my.template.cgt".dropLast(4), item("my.template.cgt").displayName) + assertEquals("readme.txt", item("readme.txt").displayName) + } + + @Test + fun primaryTemplate_isFirst_orEmptyFallback() { + val a = TemplateMetadata("A", "da", "1.0") + val b = TemplateMetadata("B", "db", "2.0") + assertEquals(a, item("x.cgt", listOf(a, b)).primaryTemplate) + + val empty = item("x.cgt", emptyList()).primaryTemplate + assertEquals("", empty.name) + assertEquals("", empty.version) + } + + @Test + fun hasMultipleTemplates_reflectsCount() { + assertFalse(item("x.cgt", listOf(TemplateMetadata("A", "", "1"))).hasMultipleTemplates) + assertTrue( + item("x.cgt", listOf(TemplateMetadata("A", "", "1"), TemplateMetadata("B", "", "1"))) + .hasMultipleTemplates + ) + assertFalse(item("x.cgt", emptyList()).hasMultipleTemplates) + } + + @Test + fun versionLabel_prefixesWithV() { + assertEquals("v1.0", versionLabel("1.0")) + assertEquals("v0.1", versionLabel("0.1")) + assertEquals("v1.2.3", versionLabel("1.2.3")) + } + + @Test + fun versionLabel_truncatesMoreThanThreeSegments() { + // Only the first three dot-separated segments are kept (matches the host Plugin Manager). + assertEquals("v1.0.0-build...", versionLabel("1.0.0-build.20260101")) + assertEquals("v1.2.3...", versionLabel("1.2.3.4")) + } + + @Test + fun versionLabel_blankBecomesEmpty() { + assertEquals("", versionLabel("")) + assertEquals("", versionLabel(" ")) + } +} diff --git a/template-manager/src/test/kotlin/org/appdevforall/templatemanagerplugin/parsing/CgtTemplateReaderTest.kt b/template-manager/src/test/kotlin/org/appdevforall/templatemanagerplugin/parsing/CgtTemplateReaderTest.kt new file mode 100644 index 00000000..f2026d34 --- /dev/null +++ b/template-manager/src/test/kotlin/org/appdevforall/templatemanagerplugin/parsing/CgtTemplateReaderTest.kt @@ -0,0 +1,110 @@ +package org.appdevforall.templatemanagerplugin.parsing + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class CgtTemplateReaderTest { + + /** Builds an in-memory .cgt (zip) from a map of entry path -> contents. */ + private fun cgt(entries: Map): ByteArrayInputStream { + val bytes = ByteArrayOutputStream() + ZipOutputStream(bytes).use { zip -> + for ((path, content) in entries) { + zip.putNextEntry(ZipEntry(path)) + zip.write(content.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + } + return ByteArrayInputStream(bytes.toByteArray()) + } + + @Test + fun readsSingleTemplateMetadata() { + val input = cgt( + mapOf( + "pkg/template/template.json" to + """{"name":"Basic Activity","description":"Creates a new basic activity","version":"0.1"}""" + ) + ) + val result = CgtTemplateReader.readTemplates(input) + assertEquals(1, result.size) + assertEquals("Basic Activity", result[0].name) + assertEquals("Creates a new basic activity", result[0].description) + assertEquals("0.1", result[0].version) + assertTrue(result[0].optionalTags.isEmpty()) + } + + @Test + fun readsAllTemplatesInMultiTemplateArchive() { + val input = cgt( + mapOf( + "a/template/template.json" to """{"name":"Empty","description":"e","version":"1.0"}""", + "b/template/template.json" to """{"name":"Login","description":"l","version":"1.1"}""", + "a/build.gradle.kts.peb" to "// not a template.json" + ) + ) + val result = CgtTemplateReader.readTemplates(input) + assertEquals(2, result.size) + assertEquals(setOf("Empty", "Login"), result.map { it.name }.toSet()) + } + + @Test + fun parsesOptionalParametersAsTagWithIdentifier() { + val input = cgt( + mapOf( + "pkg/template/template.json" to """ + { + "name":"T","description":"d","version":"1.0", + "parameters": { "optional": { + "language": {"identifier":"LANGUAGE"}, + "minsdk": {"identifier":"MIN_SDK"} + } } + } + """.trimIndent() + ) + ) + val tags = CgtTemplateReader.readTemplates(input).single().optionalTags + // org.json key iteration order isn't guaranteed, so compare as a set. + assertEquals(setOf("language (LANGUAGE)", "minsdk (MIN_SDK)"), tags.toSet()) + } + + @Test + fun handlesUnquotedInnerKeys_asShippedByCore() { + // The bundled core.cgt uses lenient JSON with unquoted inner keys; org.json accepts it. + val input = cgt( + mapOf( + "BasicActivity/template/template.json" to """ + { + "name":"Basic Activity","description":"d","version":"0.1", + "parameters": { "optional": { "language": {identifier: "LANGUAGE"} } } + } + """.trimIndent() + ) + ) + val template = CgtTemplateReader.readTemplates(input).single() + assertEquals("Basic Activity", template.name) + assertEquals(listOf("language (LANGUAGE)"), template.optionalTags) + } + + @Test + fun optionalTagWithoutIdentifierFallsBackToKey() { + val input = cgt( + mapOf( + "pkg/template/template.json" to + """{"name":"T","description":"d","version":"1.0","parameters":{"optional":{"flag":{}}}}""" + ) + ) + assertEquals(listOf("flag"), CgtTemplateReader.readTemplates(input).single().optionalTags) + } + + @Test + fun returnsEmptyWhenNoTemplateJson() { + val input = cgt(mapOf("pkg/readme.txt" to "hello", "pkg/template/other.json" to "{}")) + assertTrue(CgtTemplateReader.readTemplates(input).isEmpty()) + } +} diff --git a/template-manager/template-manager.html b/template-manager/template-manager.html new file mode 100644 index 00000000..506a67ac --- /dev/null +++ b/template-manager/template-manager.html @@ -0,0 +1,163 @@ + + + + + + Template Manager — Documentation + + + + +

Template Manager

+

A Code On The Go plugin for browsing, installing, uninstalling, and deleting project/file templates (.cgt files) from inside the IDE.

+ +

Executive Overview

+

+ Code On The Go ships project and file templates as .cgt archives — the + building blocks the New Project / New File wizard offers. Normally the only way + to add or remove a template is to hand-edit the IDE's template store on disk. + Template Manager replaces that with a single on-device screen: it lists every + template the IDE already has and every .cgt sitting in your Downloads folder, and + lets you install, uninstall, or delete them with one tap. +

+

+ Install this plugin if you download community .cgt templates, build your own, or + want to prune the templates that appear in the New Project wizard — all without leaving the IDE + or touching a file manager. +

+ +

Core Functionality

+
    +
  • Unified template list — one card per .cgt, titled with the file's own + name and showing its version and status (a single-template file also shows its description). + Installed templates (registered with the IDE) are marked green; + Not installed templates found in Downloads are marked red.
  • +
  • Install — registers a Downloads .cgt with the IDE and moves the file into + the template store, so it appears in the New Project / New File wizard.
  • +
  • Uninstall — unregisters a template and restores the .cgt back to Downloads + (the Downloads copy is written before the store copy is removed, so a failed restore + never loses your only copy).
  • +
  • Delete — permanently removes a .cgt from Downloads, after a confirmation + dialog.
  • +
  • Multi-template bundles — a single .cgt can carry several templates. Such + cards show a “Contains N templates” note and open a sub-screen with one card per + bundled template, each with its own Details.
  • +
  • Details — version, description, and any optional wizard parameters declared under the + template's parameters.optional.
  • +
+ +

Technical Architecture

+ +

Module layout

+
src/main/
+├── kotlin/org/appdevforall/templatemanagerplugin/
+│   ├── TemplateManagerPlugin.kt              IPlugin + UIExtension + EditorTabExtension + DocumentationExtension
+│   ├── fragments/TemplateManagerPluginFragment.kt  dashboard UI + install/uninstall/delete logic
+│   ├── adapters/CgtFileAdapter.kt            main list adapter + per-file overflow menu
+│   ├── adapters/TemplateCardAdapter.kt       per-template cards for multi-template bundles
+│   ├── parsing/CgtTemplateReader.kt          pure .cgt (zip) → template metadata reader
+│   └── models/CgtFileItem.kt                 card model + TemplateMetadata
+├── assets/docs/                              Tier 3 offline in-app help (index.html)
+├── assets/icon_day.png / icon_night.png      Plugin Manager icons
+└── AndroidManifest.xml                       plugin.* metadata
+

+ A .cgt is a zip whose <path>/template/template.json entries describe + each bundled template. CgtTemplateReader parses those entries into in-memory metadata + (it reads entries to a byte[] and never extracts to disk). Directory scanning and zip + parsing run on a background dispatcher; results are applied on the main thread. +

+ +

IDE services used

+
    +
  • IdeTemplateServiceregisterTemplate / unregisterTemplate / + reloadTemplates to add and remove templates from the store.
  • +
  • IdeEnvironmentService — locates the IDE home directory to find the template store.
  • +
  • IdeTooltipService — shows the in-app help tooltip on a long-press.
  • +
  • IdeEditorTabService — opens the plugin's main editor tab from the sidebar entry.
  • +
+ +

Permissions

+ + + + +
PermissionWhy
filesystem.readList and parse .cgt files in Downloads and the template store.
filesystem.writeRegister / unregister templates and move / delete .cgt files.
+

No network, system-command, or native-code permission is requested.

+ +

Lifecycle & resource discipline

+

+ Background scans run in the fragment's viewLifecycleOwner.lifecycleScope and are + cancelled when the view is destroyed or a new scan starts; the zip stream is closed via + use { }; the RecyclerView adapter and view references are cleared in + onDestroyView(). The plugin holds no static IDE references. +

+ +

Usage

+
    +
  1. Open Template Manager from the IDE's left sidebar (or its editor tab).
  2. +
  3. Place any .cgt you want to install in your Download/ folder; it appears + as a red Not installed card.
  4. +
  5. Use each card's overflow (⋮) menu to Install, Uninstall, Delete, or view + Details.
  6. +
  7. Long-press any card for in-app help; the tooltip's guide button opens the full offline + documentation.
  8. +
+

Build from source with the repo-root Gradle wrapper:

+
./gradlew assemblePlugin        # release  -> build/plugin/templatemanagerplugin.cgp
+./gradlew assemblePluginDebug   # debug
+ +

Key Benefits

+
    +
  • Manage the New Project / New File template catalog entirely on-device — no file manager, no ADB.
  • +
  • Safe uninstall that restores the .cgt to Downloads before removing the store copy.
  • +
  • Understand exactly what a .cgt contains — including multi-template bundles and their + optional wizard parameters — before installing it.
  • +
  • Minimal, filesystem-only permissions; no network access.
  • +
+ + +