diff --git a/README.md b/README.md
index 78f986f..5695772 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego
| [`markdown-preview/`](markdown-preview/) | Renders Markdown files with a live preview pane in the editor. |
| [`keystore-generator/`](keystore-generator/) | Generates signing keystores from inside the IDE. |
| [`snippets/`](snippets/) | Adds user-managed code snippets with prefix-triggered expansions. |
+| [`random-xkcd/`](random-xkcd/) | Random xkcd comic in the editor bottom sheet; canonical small-plugin walkthrough with in-IDE help. |
## Building a plugin
diff --git a/libs/plugin-api.jar b/libs/plugin-api.jar
index 1c07a22..d46c012 100644
Binary files a/libs/plugin-api.jar and b/libs/plugin-api.jar differ
diff --git a/random-xkcd/.gitignore b/random-xkcd/.gitignore
new file mode 100644
index 0000000..6af499b
--- /dev/null
+++ b/random-xkcd/.gitignore
@@ -0,0 +1,29 @@
+# Gradle
+.gradle/
+build/
+gradle-app.setting
+!gradle-wrapper.jar
+.gradletasknamecache
+
+# IDE
+.idea/
+*.iml
+*.ipr
+*.iws
+.project
+.classpath
+.settings/
+.kotlin/
+
+# Local configuration
+local.properties
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# Test outputs
+test-results/
diff --git a/random-xkcd/README.md b/random-xkcd/README.md
new file mode 100644
index 0000000..4695c14
--- /dev/null
+++ b/random-xkcd/README.md
@@ -0,0 +1,90 @@
+# random-xkcd
+
+A small Code on the Go plugin that shows a random xkcd comic in the
+editor bottom sheet. Tap for a new comic, double-tap to copy the URL,
+triple-tap to copy the image. Long-press the tab for in-IDE help.
+
+Designed as a canonical "this is what a small CoGo plugin looks like"
+example. Under 300 lines of Kotlin, every plugin-specific concept
+called out where it shows up in the code.
+
+## The tutorial
+
+The full walkthrough lives in `src/main/assets/docs/index.html` —
+the **Tier 3 docs page** served by the host IDE at
+`http://localhost:6174/plugin/com.codeonthego.xkcdrandom/index.html`
+once the plugin is installed.
+
+To read it:
+
+- **Inside CoGo** (the canonical path) — long-press the **XKCD** tab in
+ the editor bottom sheet → tap **"See More"** → tap **"Code
+ walkthrough"**. The IDE opens the page in an in-IDE WebView.
+- **Outside CoGo** — open `src/main/assets/docs/index.html` directly
+ in any browser. Renders identically.
+
+The tutorial covers the plugin in 7 steps:
+
+1. Plugin entry point (`IPlugin` lifecycle)
+2. Manifest + permissions
+3. Bottom-sheet tab UI (`UIExtension`)
+4. Tap interactions (single / double / triple)
+5. Network fetch over HTTPS
+6. Clipboard support (text + image via host `FileProvider`)
+7. Three-tier tooltip help (`DocumentationExtension`)
+
+## Build
+
+```bash
+./gradlew assemblePlugin
+```
+
+Produces `build/plugin/random-xkcd.cgp` — the bundle you sideload
+into Code on the Go via **Preferences → Plugin Manager → +**.
+
+## Source layout
+
+```
+random-xkcd/
+├── build.gradle.kts
+└── src/main/
+ ├── AndroidManifest.xml
+ ├── assets/
+ │ ├── docs/ ← Tier 3 walkthrough (the tutorial)
+ │ ├── icon_day.png ← Plugin Manager icon, light theme
+ │ └── icon_night.png ← Plugin Manager icon, dark theme
+ ├── kotlin/com/codeonthego/xkcdrandom/
+ │ ├── XkcdRandomPlugin.kt ← lifecycle + tab + tooltip registration
+ │ ├── fragments/XkcdPanelFragment.kt
+ │ ├── net/XkcdApiClient.kt ← HTTP, two endpoints, no auth
+ │ ├── net/XkcdComic.kt
+ │ └── ui/TapCountClassifier.kt ← 1/2/3 tap state machine
+ └── res/
+ ├── layout/fragment_xkcd_panel.xml
+ └── values/, values-night/
+```
+
+Plus unit tests under `src/test/` for the tap classifier
+(JUnit 4 + Truth, no Robolectric).
+
+## Run tests
+
+```bash
+./gradlew testDebugUnitTest
+```
+
+## xkcd attribution + license
+
+xkcd comics are © Randall Munroe and licensed **CC BY-NC 2.5**
+(https://xkcd.com/license.html). This plugin:
+
+- Fetches comics over HTTPS from xkcd.com (no caching, no redistribution
+ beyond what the user explicitly copies to their own clipboard).
+- Displays an attribution line — *"Comics © Randall Munroe · xkcd.com ·
+ CC BY-NC 2.5"* — beneath every comic in the bottom-sheet panel.
+- Is itself non-commercial (open-source demo plugin for an
+ open-source IDE), consistent with the NC term.
+
+The plugin's own source code is licensed per the surrounding
+`plugin-examples` repository (see `LICENSE` at the repo root). xkcd's
+license applies only to the comic content the plugin displays.
diff --git a/random-xkcd/build.gradle.kts b/random-xkcd/build.gradle.kts
new file mode 100644
index 0000000..2efaf76
--- /dev/null
+++ b/random-xkcd/build.gradle.kts
@@ -0,0 +1,93 @@
+import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("com.itsaky.androidide.plugins.build")
+}
+
+pluginBuilder {
+ pluginName = "random-xkcd"
+}
+
+android {
+ namespace = "com.codeonthego.xkcdrandom"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.codeonthego.xkcdrandom"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/versions/9/OSGI-INF/MANIFEST.MF",
+ "META-INF/DEPENDENCIES",
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/NOTICE",
+ "META-INF/NOTICE.txt"
+ )
+ }
+ }
+
+ testOptions {
+ unitTests.isReturnDefaultValues = true
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget.set(JvmTarget.JVM_17)
+ }
+}
+
+dependencies {
+ // The plugin-api jar is the canonical contract for plugins. Available
+ // at compile time; the IDE provides it at runtime.
+ compileOnly(files("../libs/plugin-api.jar"))
+
+ implementation("androidx.appcompat:appcompat:1.6.1")
+ implementation("com.google.android.material:material:1.10.0")
+ implementation("androidx.fragment:fragment-ktx:1.8.8")
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
+
+ // OkHttp for the xkcd JSON endpoint + image fetch.
+ // Kept tiny and dependency-free — no Glide/Retrofit, since this plugin is a
+ // teaching example and we want the network layer to read top-to-bottom.
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+
+ testImplementation("junit:junit:4.13.2")
+}
+
+tasks.wrapper {
+ gradleVersion = "8.14.3"
+ distributionType = Wrapper.DistributionType.BIN
+}
+
+// Disable AAR metadata checks that fail under the plugin-builder
+// pipeline (Beepy + Forms use the same workaround).
+tasks.matching {
+ it.name.contains("checkDebugAarMetadata") ||
+ it.name.contains("checkReleaseAarMetadata")
+}.configureEach {
+ enabled = false
+}
diff --git a/random-xkcd/gradle.properties b/random-xkcd/gradle.properties
new file mode 100644
index 0000000..2c9f545
--- /dev/null
+++ b/random-xkcd/gradle.properties
@@ -0,0 +1,3 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
diff --git a/random-xkcd/gradle/wrapper/gradle-wrapper.jar b/random-xkcd/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 0000000..8bdaf60
Binary files /dev/null and b/random-xkcd/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/random-xkcd/gradle/wrapper/gradle-wrapper.properties b/random-xkcd/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4081da
--- /dev/null
+++ b/random-xkcd/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/random-xkcd/gradlew b/random-xkcd/gradlew
new file mode 100755
index 0000000..ef07e01
--- /dev/null
+++ b/random-xkcd/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 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\n' "$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="\\\"\\\""
+
+
+# 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, 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" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# 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/random-xkcd/gradlew.bat b/random-xkcd/gradlew.bat
new file mode 100755
index 0000000..db3a6ac
--- /dev/null
+++ b/random-xkcd/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/random-xkcd/proguard-rules.pro b/random-xkcd/proguard-rules.pro
new file mode 100644
index 0000000..2ccccf8
--- /dev/null
+++ b/random-xkcd/proguard-rules.pro
@@ -0,0 +1,4 @@
+# Add project specific ProGuard rules here.
+
+# Keep plugin classes — the IDE loads them by reflection via plugin.main_class.
+-keep class com.codeonthego.xkcdrandom.** { *; }
diff --git a/random-xkcd/settings.gradle.kts b/random-xkcd/settings.gradle.kts
new file mode 100644
index 0000000..3b72163
--- /dev/null
+++ b/random-xkcd/settings.gradle.kts
@@ -0,0 +1,30 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath(files("../libs/plugin-api.jar"))
+ classpath(files("../libs/gradle-plugin.jar"))
+ classpath("com.android.tools.build:gradle:8.11.0")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.0")
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "random-xkcd"
diff --git a/random-xkcd/src/main/AndroidManifest.xml b/random-xkcd/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f88a914
--- /dev/null
+++ b/random-xkcd/src/main/AndroidManifest.xml
@@ -0,0 +1,62 @@
+
+
A 7-step tour of how a small Code on the Go plugin is +built end-to-end. Each step adds one capability and teaches one +plugin-platform concept. The whole plugin is under 300 lines of +Kotlin — small enough to read top-to-bottom in an evening.
+ +Why you care: every plugin starts with an
+IPlugin class. The host loads it via
+DexClassLoader, reflectively instantiates it from
+plugin.main_class, and drives the
+initialize → activate → deactivate → dispose lifecycle.
+Get this right and the rest is opt-in.
class XkcdRandomPlugin : IPlugin {
+
+ private lateinit var context: PluginContext
+
+ companion object {
+ const val PLUGIN_ID = "com.codeonthego.xkcdrandom"
+ }
+
+ override fun initialize(context: PluginContext): Boolean {
+ return try {
+ this.context = context
+ context.logger.info("XkcdRandomPlugin initialized")
+ true
+ } catch (t: Throwable) {
+ context.logger.error("XkcdRandomPlugin initialization failed", t)
+ false
+ }
+ }
+
+ override fun activate(): Boolean { … }
+ override fun deactivate(): Boolean { … }
+ override fun dispose() { … }
+}
+
+Plugin concept — wrap initialize
+in try/catch. A stray exception here crashes the host IDE on plugin
+load. Return false on failure; the host then skips
+activate() for this plugin and keeps the rest of the IDE
+alive.
Plugin concept — PluginContext
+is your handle to the host. It exposes a per-plugin
+logger, a services registry (look up
+IdeEditorTabService, IdeTooltipService, etc.),
+and resource access. Stash the reference in initialize and
+use it from every other method.
Why you care: the manifest tells the host how +to find your plugin and what host resources you're allowed to touch.
+ +<manifest xmlns:android="http://schemas.android.com/apk/res/android"> + <application + android:label="@string/app_name" + android:theme="@style/PluginTheme"> + + <meta-data android:name="plugin.id" + android:value="com.codeonthego.xkcdrandom" /> + <meta-data android:name="plugin.name" + android:value="Random XKCD" /> + <meta-data android:name="plugin.main_class" + android:value="com.codeonthego.xkcdrandom.XkcdRandomPlugin" /> + <meta-data android:name="plugin.permissions" + android:value="network.access" /> + </application> +</manifest>+ +
Plugin permissions are comma-separated values inside one
+<meta-data> entry — not the
+<uses-permission> system Android apps use. Available
+permissions:
| Permission | What it gates |
|---|---|
network.access | Required by the validator for any plugin that contacts the network. (Today the runtime gate is wired in, advisory for plugins — declare it anyway; it's forward-compatible.) |
filesystem.read | Read access to the user's project via IdeFileService, IdeEditorService, etc. |
filesystem.write | Write access to the user's project. Same gate as above. |
system.commands | Runtime.exec, process spawning. |
ide.settings | Read/write IDE preferences. |
project.structure | Walk the user's open project. |
native.code | Execute native machine code. |
ide.environment.write | Write to IDE-managed dirs (SDK, NDK, cache). |
Plugin concept — permissions gate the host's
+resources, not your own. Your context.filesDir and
+context.cacheDir belong to the host APK's Android
+sandbox and are yours to use freely. The system clipboard is
+reachable directly via ClipboardManager. So even though
+this plugin writes to filesDir (the triple-tap
+clipboard hop in Step 6) and writes to the clipboard, the
+only permission it needs is network.access. Declare
+permissions to mean "I touch what the host mediates" — nothing
+else.
Why you care: bottom-sheet tabs are the primary
+place small plugins live in CoGo. They appear alongside Build Output,
+App Logs, IDE Logs, etc. — visible without leaving the editor. You
+register one with UIExtension.getEditorTabs() and
+provide a Fragment factory.
class XkcdRandomPlugin : IPlugin, UIExtension {
+
+ override fun getEditorTabs(): List<TabItem> = listOf(
+ TabItem(
+ id = "xkcd_bottom_tab",
+ title = "XKCD",
+ fragmentFactory = { XkcdPanelFragment() },
+ order = 200,
+ tooltipTag = "xkcd.tab"
+ )
+ )
+}
+
+tooltipTag connects this tab to the help entry from
+DocumentationExtension (Step 7). Omit it and the host
+falls back to "<pluginId>.<tabId>".
The Fragment needs one non-obvious override:
+ +override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
+ val inflater = super.onGetLayoutInflater(savedInstanceState)
+ return PluginFragmentHelper.getPluginInflater(XkcdRandomPlugin.PLUGIN_ID, inflater)
+}
+
+Plugin concept — plugin Fragments must wrap
+their LayoutInflater. Plugins load via
+DexClassLoader. The inflater the host hands you defaults
+to resolving R.layout.* against the host IDE's
+resources, not yours. Skip the wrap and you crash with
+Resources$NotFoundException the first time the Fragment
+shows. PluginFragmentHelper.getPluginInflater(pluginId, parent)
+wraps the inflater with a Resources instance backed by
+your plugin's APK.
Plugin concept —
+fragmentFactory returns a fresh instance every time.
+Never cache a singleton Fragment; the host calls the factory whenever
+the tab is shown, and Fragment lifecycle expectations require a clean
+instance each time.
Why you care: Android's
+GestureDetector resolves single + double tap but not
+triple. The XKCD plugin needs all three (tap = new comic, double-tap
+= copy URL, triple-tap = copy image), so it rolls a small
+TapCountClassifier state machine. It's clean enough to
+lift into your own plugin if you need tap-burst handling.
// TapCountClassifier — pure state machine, no Android imports,
+// no clocks. The Fragment supplies `now` (uptime millis) and
+// decides when to resolve(). Makes the classifier unit-testable
+// in plain JUnit.
+class TapCountClassifier(private val windowMs: Long = DEFAULT_WINDOW_MS) {
+ enum class Classification { SINGLE, DOUBLE, TRIPLE }
+ /** Returns true if this tap closed a burst (3 taps inside the window). */
+ fun onTap(nowMs: Long): Boolean { … }
+ fun resolve(): Classification? { … }
+}
+
+The Fragment's touch listener feeds each ACTION_UP
+into the classifier, but only when the gesture didn't move beyond the
+system touch slop — otherwise scrolling a tall comic would also
+register as a tap:
val touchSlop = ViewConfiguration.get(view.context).scaledTouchSlop
+root.setOnTouchListener { _, event ->
+ when (event.actionMasked) {
+ MotionEvent.ACTION_DOWN -> { downX = event.x; downY = event.y }
+ MotionEvent.ACTION_UP -> {
+ val dx = event.x - downX; val dy = event.y - downY
+ if (dx * dx + dy * dy <= touchSlop * touchSlop) {
+ handleTap(); root.performClick()
+ }
+ }
+ }
+ false // never consume — let ScrollView keep scrolling
+}
+
+Why you care: most plugins talk to a network
+eventually. The XKCD client is the smallest realistic example:
+OkHttp + the org.json reader that ships with Android, no
+auth, two endpoints. It also shows the offline-failure contract —
+returning null rather than throwing keeps the caller's
+empty-state branch reachable on a flaky network.
class XkcdApiClient(private val client: OkHttpClient = defaultClient()) {
+
+ fun fetchRandom(): XkcdComic? {
+ val latest = fetchLatest() ?: return null
+ while (true) {
+ val pick = Random.nextInt(1, latest.num + 1)
+ if (pick == 404) continue
+ fetchByNumber(pick)?.let { return it }
+ // null = transient blip; just pick again
+ }
+ }
+
+ fun fetchLatest(): XkcdComic? = getJson(LATEST_URL)?.let(::parseComic)
+ fun fetchByNumber(num: Int): XkcdComic? = getJson(numUrl(num))?.let(::parseComic)
+}
+
+Why the loop is unbounded: xkcd #404
+is a joke comic that returns HTTP 404 on its JSON endpoint. A
+bounded retry can still land on the same dud. Looping until success
+is simpler and converges in 1-2 picks on a healthy network. The only
+way fetchRandom returns null is if the
+initial probe fails — i.e. the network is down.
The Fragment wires this into a coroutine + bitmap decode on
+Dispatchers.IO so the UI stays responsive:
private fun loadRandomComic() {
+ if (loadJob?.isActive == true) return
+ if (currentComic == null) showLoading()
+ loadJob = viewLifecycleOwner.lifecycleScope.launch {
+ val result = withContext(Dispatchers.IO) { fetchAndDecode() }
+ // … render or show empty state
+ }
+}
+
+For production use on low-end devices, prefer
+Android's bounded
+bitmap decoding pattern. This plugin keeps it simple:
+BitmapFactory.decodeByteArray(...) with a 5 MB
+network-read cap.
Why you care: moving things to the clipboard is
+how plugins integrate with the rest of the user's workflow. Text
+clipboard is trivial; image clipboard takes one detour through the
+host's FileProvider because of how Android's content URIs
+work in the plugin sandbox.
private fun copyUrlToClipboard() {
+ val comic = currentComic ?: return
+ val cm = requireContext().getSystemService(CLIPBOARD_SERVICE) as ClipboardManager
+ cm.setPrimaryClip(ClipData.newPlainText("xkcd-url", comic.pageUrl))
+ toast(getString(R.string.toast_url_copied, comic.pageUrl))
+}
+
+No permission needed. Android doesn't
+have a <uses-permission> for clipboard access either
+— clipboard write is implicitly allowed for foreground apps (which
+your plugin always is, because its tab is in the foreground IDE).
+Android 12+ adds a system toast on clipboard reads, but
+writes pass freely.
private fun copyImageToClipboard() {
+ val bytes = lastBytes ?: run { toast(/* failed */); return }
+ val ctx = requireContext()
+ viewLifecycleOwner.lifecycleScope.launch {
+ val target = withContext(Dispatchers.IO) {
+ val shareDir = File(ctx.filesDir, "xkcd_share").apply { mkdirs() }
+ val out = File(shareDir, "last.png")
+ try { out.writeBytes(bytes); out } catch (_: Exception) { null }
+ } ?: run { toast(/* failed */); return@launch }
+
+ val authority = "${ctx.packageName}.providers.fileprovider"
+ val uri = FileProvider.getUriForFile(ctx, authority, target)
+ val clip = ClipData.newUri(ctx.contentResolver, "xkcd-image", uri)
+ (ctx.getSystemService(CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(clip)
+ toast(/* image copied */)
+ }
+}
+
+Plugin concept — <provider>
+declarations in a plugin manifest are dead code. Plugins are
+loaded via DexClassLoader, not installed as Android
+apps. Android's PackageManager never sees the plugin's
+manifest, so it never registers your <activity>,
+<service>, or <provider>.
+Anything that requires OS-side registration must go through the host.
The escape valve: route through the host IDE's
+FileProvider authority. The host ships its own
+FileProvider with authority
+${packageName}.providers.fileprovider and a
+file_provider_paths.xml that exposes
+filesDir. Any file we drop under
+ctx.filesDir/... can be served from that authority.
ctx.packageName is the host's package name (because
+ctx is the host's Context), so this composes
+to the right authority without hard-coding it. And because
+filesDir is your plugin's own sandbox storage, writing
+to it doesn't need filesystem.write.
ClipData.newUri queries the ContentResolver
+for the URI's MIME type. The host's FileProvider resolves
+.png to image/png, so the clip advertises
+image/* to paste targets. Paste into Messages, Gmail, any
+image-aware app — it pastes the image, not the URL.
Why you care: CoGo has a per-plugin help API.
+A user long-presses your plugin's tab and gets in-IDE help that you
+provide. Three tiers, progressively-detailed: a one-liner, then an
+HTML detail panel, then a full HTML walkthrough page (this page).
+Implementing DocumentationExtension is how a plugin
+provides help users can discover without leaving the IDE.
class XkcdRandomPlugin : IPlugin, UIExtension, DocumentationExtension {
+
+ override fun getTooltipCategory(): String = "plugin_xkcd"
+
+ override fun getTooltipEntries(): List<PluginTooltipEntry> = listOf(
+ PluginTooltipEntry(
+ tag = "xkcd.tab",
+ summary = "Random xkcd comic. Tap to roll a new one.",
+ detail = """
+ <p>This panel pulls a random comic from <b>xkcd.com</b>.</p>
+ <ul>
+ <li><b>Tap</b> — fetch a new random comic.</li>
+ <li><b>Double-tap</b> — copy the URL.</li>
+ <li><b>Triple-tap</b> — copy the image.</li>
+ </ul>
+ """.trimIndent(),
+ buttons = listOf(
+ PluginTooltipButton(
+ description = "Code walkthrough",
+ uri = "index.html",
+ order = 0
+ )
+ )
+ )
+ )
+
+ override fun getTier3DocsAssetPath(): String? = "docs"
+}
+
+| Tier | Field | What the user sees |
|---|---|---|
| 1 | summary | Short string shown when long-pressing your tab. |
| 2 | detail | HTML rendered inside the tooltip after tapping "See More". |
| 3 | buttons[].uri | A button labeled description. Tapping it opens an HTML page (this one) served at http://localhost:6174/plugin/<pluginId>/<uri>. |
tag here is the same string you put on
+TabItem.tooltipTag (Step 3) — that's the wire that
+connects the long-press on the tab to this entry.
getTier3DocsAssetPath() = "docs" says: "my Tier 3
+walkthrough lives under src/main/assets/docs/." At
+install time the host's Tier3AssetWalker indexes
+everything under that directory and serves each file at the URL
+above. Files reference each other with relative paths — so this
+page's <link rel="stylesheet" href="css/walkthrough.css">
+just works.
To preview the Tier 3 page outside the
+IDE: just open random-xkcd/src/main/assets/docs/index.html
+in any browser. It renders identically; the localhost:6174 URL is
+only used when the host serves it inside CoGo.
Three rules to remember:
+<provider>, <activity>,
+ <service>, <receiver> are dead code —
+ route via host extensions or its FileProvider.R.* resolves against whichever Resources
+ your code is using — wrap the inflater.filesDir, cacheDir, and the clipboard
+ need no declaration.xkcd comics are © Randall Munroe, licensed
+CC BY-NC 2.5. This plugin
+fetches over HTTPS, shows a permanent attribution line under every
+comic, and is itself a non-commercial open-source demo. The plugin's
+source is licensed per the plugin-examples repo.
forms-plugin and
+ maps-plugin show plugins with more complex UI and
+ persistent state.XkcdRandomPlugin.kt →
+ XkcdPanelFragment.kt → XkcdApiClient.kt →
+ TapCountClassifier.kt.This panel pulls a random comic from xkcd.com.
+Fetches use HTTPS only.
+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "Code walkthrough", + uri = "index.html", // resolves to plugin/