From 3f84186d3f2d539e3863dc40498848a8e9820ed3 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 14:59:30 +0200 Subject: [PATCH 01/16] random-xkcd: scaffold module + plugin entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the build, manifest skeleton, and `IPlugin` lifecycle class for a new Code on the Go plugin. The host loads this class via `DexClassLoader` by the fully-qualified name in `plugin.main_class`, then drives initialize → activate → deactivate → dispose. Wrap initialize() in try/catch so a stray exception in plugin setup doesn't crash the host IDE — returning false here makes the IDE skip activate() and keep running. Subsequent commits opt this class into UIExtension (bottom-sheet tab) and DocumentationExtension (in-IDE help). For now there are no extensions — the plugin loads cleanly but doesn't surface any UI. Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/.gitignore | 29 ++ random-xkcd/build.gradle.kts | 88 ++++++ random-xkcd/gradle.properties | 3 + random-xkcd/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + random-xkcd/gradlew | 251 ++++++++++++++++++ random-xkcd/gradlew.bat | 94 +++++++ random-xkcd/proguard-rules.pro | 4 + random-xkcd/settings.gradle.kts | 30 +++ random-xkcd/src/main/AndroidManifest.xml | 35 +++ .../xkcdrandom/XkcdRandomPlugin.kt | 55 ++++ 11 files changed, 596 insertions(+) create mode 100644 random-xkcd/.gitignore create mode 100644 random-xkcd/build.gradle.kts create mode 100644 random-xkcd/gradle.properties create mode 100755 random-xkcd/gradle/wrapper/gradle-wrapper.jar create mode 100644 random-xkcd/gradle/wrapper/gradle-wrapper.properties create mode 100755 random-xkcd/gradlew create mode 100755 random-xkcd/gradlew.bat create mode 100644 random-xkcd/proguard-rules.pro create mode 100644 random-xkcd/settings.gradle.kts create mode 100644 random-xkcd/src/main/AndroidManifest.xml create mode 100644 random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt 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/build.gradle.kts b/random-xkcd/build.gradle.kts new file mode 100644 index 0000000..6e95a16 --- /dev/null +++ b/random-xkcd/build.gradle.kts @@ -0,0 +1,88 @@ +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 = 28 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + // The plugin-api jar is the canonical contract for plugins. Available + // at compile time; the IDE provides it at runtime. + compileOnly(files("../libs/plugin-api.jar")) + + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("androidx.core:core-ktx:1.13.1") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + testImplementation("junit:junit:4.13.2") +} + +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 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 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..6bf0ed3 --- /dev/null +++ b/random-xkcd/src/main/AndroidManifest.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt new file mode 100644 index 0000000..2cb7515 --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt @@ -0,0 +1,55 @@ +package com.codeonthego.xkcdrandom + +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext + +/** + * Random-xkcd demo plugin — entry point. + * + * Every plugin starts with an [IPlugin] class that the host loads via + * `DexClassLoader`. The host reflectively instantiates this class by + * the fully-qualified name in `plugin.main_class` (see manifest), then + * drives the lifecycle: + * + * initialize → activate → (use) → deactivate → dispose + * + * Subsequent commits opt this class into [UIExtension] (for a + * bottom-sheet tab) and [DocumentationExtension] (for the in-IDE help + * tooltip + Tier-3 walkthrough). + */ +class XkcdRandomPlugin : IPlugin { + + private lateinit var context: PluginContext + + companion object { + const val PLUGIN_ID = "com.codeonthego.xkcdrandom" + } + + override fun initialize(context: PluginContext): Boolean { + // initialize() returns Boolean — the IDE skips activate() if this + // returns false. Wrap in try/catch so a stray exception in our + // setup can't crash the host IDE. + 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 { + context.logger.info("XkcdRandomPlugin activated") + return true + } + + override fun deactivate(): Boolean { + context.logger.info("XkcdRandomPlugin deactivated") + return true + } + + override fun dispose() { + context.logger.info("XkcdRandomPlugin disposed") + } +} From b2a36a6e54e32b5a877bffff27cdd93b2ff85027 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 14:59:53 +0200 Subject: [PATCH 02/16] random-xkcd: declare network.access permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins declare permissions via comma-separated values in a single `plugin.permissions` meta-data entry — not Android's `` system. The XKCD plugin needs exactly one: `network.access`, to fetch the xkcd JSON + image. CoGo's filesystem.* permissions gate access to the host IDE's project files, not the plugin's own `filesDir`/`cacheDir` — those are sandbox-allowed and need no declaration. The system clipboard also has no permission gate (no `clipboard.*` exists; Android itself doesn't gate clipboard either). So even with the triple-tap image- clipboard flow that comes in a later commit, this remains the only permission this plugin declares. The Tier-3 walkthrough that arrives later explains the conceptual model: permissions in CoGo gate the host's resources, not your own. Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/src/main/AndroidManifest.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/random-xkcd/src/main/AndroidManifest.xml b/random-xkcd/src/main/AndroidManifest.xml index 6bf0ed3..80a28bf 100644 --- a/random-xkcd/src/main/AndroidManifest.xml +++ b/random-xkcd/src/main/AndroidManifest.xml @@ -26,6 +26,18 @@ android:name="plugin.min_ide_version" android:value="1.0.0" /> + + + From 51abd1c418513793ec08950e8791a930738393c6 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 15:00:27 +0200 Subject: [PATCH 03/16] random-xkcd: register bottom-sheet tab + Fragment shell Opts the plugin class into `UIExtension` and registers one tab in the editor bottom sheet alongside Build Output, App Logs, etc. The host's `getEditorTabs()` call returns a `TabItem` with a fragmentFactory the host invokes whenever the tab is shown. The Fragment is shell-only here: a layout inflated via `PluginFragmentHelper.getPluginInflater(pluginId, parent)` (required so `R.layout.*` resolves against the plugin's APK and not the host IDE's), view bindings, and an empty-state placeholder. Tap handling, network fetch, and clipboard arrive in subsequent commits. Includes the layout XML, string resources (with a CC BY-NC 2.5 attribution string for the xkcd credit shown beneath every comic), colors for day + night themes, and the plugin theme style. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../xkcdrandom/XkcdRandomPlugin.kt | 41 +++++--- .../xkcdrandom/fragments/XkcdPanelFragment.kt | 83 ++++++++++++++++ .../main/res/layout/fragment_xkcd_panel.xml | 99 +++++++++++++++++++ .../src/main/res/values-night/colors.xml | 12 +++ random-xkcd/src/main/res/values/colors.xml | 11 +++ random-xkcd/src/main/res/values/strings.xml | 23 +++++ random-xkcd/src/main/res/values/styles.xml | 9 ++ 7 files changed, 266 insertions(+), 12 deletions(-) create mode 100644 random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt create mode 100644 random-xkcd/src/main/res/layout/fragment_xkcd_panel.xml create mode 100644 random-xkcd/src/main/res/values-night/colors.xml create mode 100644 random-xkcd/src/main/res/values/colors.xml create mode 100644 random-xkcd/src/main/res/values/strings.xml create mode 100644 random-xkcd/src/main/res/values/styles.xml diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt index 2cb7515..0a8696a 100644 --- a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt @@ -1,28 +1,25 @@ package com.codeonthego.xkcdrandom +import com.codeonthego.xkcdrandom.fragments.XkcdPanelFragment import com.itsaky.androidide.plugins.IPlugin import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.TabItem +import com.itsaky.androidide.plugins.extensions.UIExtension /** - * Random-xkcd demo plugin — entry point. + * Random-xkcd demo plugin. * - * Every plugin starts with an [IPlugin] class that the host loads via - * `DexClassLoader`. The host reflectively instantiates this class by - * the fully-qualified name in `plugin.main_class` (see manifest), then - * drives the lifecycle: - * - * initialize → activate → (use) → deactivate → dispose - * - * Subsequent commits opt this class into [UIExtension] (for a - * bottom-sheet tab) and [DocumentationExtension] (for the in-IDE help - * tooltip + Tier-3 walkthrough). + * Reading order: + * - this file: lifecycle + tab registration + * - [XkcdPanelFragment]: the bottom-sheet UI */ -class XkcdRandomPlugin : IPlugin { +class XkcdRandomPlugin : IPlugin, UIExtension { private lateinit var context: PluginContext companion object { const val PLUGIN_ID = "com.codeonthego.xkcdrandom" + const val TAB_ID = "xkcd_bottom_tab" } override fun initialize(context: PluginContext): Boolean { @@ -52,4 +49,24 @@ class XkcdRandomPlugin : IPlugin { override fun dispose() { context.logger.info("XkcdRandomPlugin disposed") } + + // --- UIExtension: register the bottom-sheet tab --- + + /** + * Register one bottom-sheet tab. The IDE shows it next to the eight + * built-in tabs (Build Output, App Logs, …) plus tabs from other + * plugins. `order` controls our position among plugin tabs only. + * + * The fragmentFactory returns a *new* fragment each time the tab is + * shown — never reuse a single Fragment instance, fragments have + * lifecycle expectations the IDE manages. + */ + override fun getEditorTabs(): List = listOf( + TabItem( + id = TAB_ID, + title = "XKCD", + fragmentFactory = { XkcdPanelFragment() }, + order = 200 + ) + ) } diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt new file mode 100644 index 0000000..88556b5 --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt @@ -0,0 +1,83 @@ +package com.codeonthego.xkcdrandom.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.codeonthego.xkcdrandom.R +import com.codeonthego.xkcdrandom.XkcdRandomPlugin +import com.itsaky.androidide.plugins.base.PluginFragmentHelper + +/** + * The "XKCD" tab body — shell. + * + * This commit lays out the Fragment + view bindings + render + * primitives. Behavior (tap handling, network fetch, clipboard) lands + * in subsequent commits. + */ +class XkcdPanelFragment : Fragment() { + + // Bound view references — populated in onViewCreated, cleared in + // onDestroyView so we don't leak views across configuration changes. + private var imageCard: FrameLayout? = null + private var imageView: ImageView? = null + private var captionView: TextView? = null + private var altView: TextView? = null + private var progressView: ProgressBar? = null + private var emptyView: TextView? = null + + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { + // Plugins must wrap the inflater so R.layout.* resolves against + // the plugin's APK resources, not the host IDE's. Without this + // you get a Resources$NotFoundException at inflate time. + val inflater = super.onGetLayoutInflater(savedInstanceState) + return PluginFragmentHelper.getPluginInflater(XkcdRandomPlugin.PLUGIN_ID, inflater) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? = inflater.inflate(R.layout.fragment_xkcd_panel, container, false) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + imageCard = view.findViewById(R.id.xkcd_image_card) + imageView = view.findViewById(R.id.xkcd_image) + captionView = view.findViewById(R.id.xkcd_caption) + altView = view.findViewById(R.id.xkcd_alt) + progressView = view.findViewById(R.id.xkcd_progress) + emptyView = view.findViewById(R.id.xkcd_empty) + + // For now, start in the empty state — fetching arrives in a later + // commit that adds the network client. + showEmptyState() + } + + override fun onDestroyView() { + imageCard = null + imageView = null + captionView = null + altView = null + progressView = null + emptyView = null + super.onDestroyView() + } + + // --- rendering --- + + private fun showEmptyState() { + progressView?.visibility = View.GONE + imageCard?.visibility = View.GONE + captionView?.visibility = View.GONE + altView?.visibility = View.GONE + emptyView?.visibility = View.VISIBLE + emptyView?.setText(R.string.empty_offline) + } +} diff --git a/random-xkcd/src/main/res/layout/fragment_xkcd_panel.xml b/random-xkcd/src/main/res/layout/fragment_xkcd_panel.xml new file mode 100644 index 0000000..65ba882 --- /dev/null +++ b/random-xkcd/src/main/res/layout/fragment_xkcd_panel.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/random-xkcd/src/main/res/values-night/colors.xml b/random-xkcd/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..27b3a77 --- /dev/null +++ b/random-xkcd/src/main/res/values-night/colors.xml @@ -0,0 +1,12 @@ + + + #B1C5FF + #172E60 + #2F4578 + #DAE2FF + + + #FFFFFF + #E6E1E5 + diff --git a/random-xkcd/src/main/res/values/colors.xml b/random-xkcd/src/main/res/values/colors.xml new file mode 100644 index 0000000..a0624a3 --- /dev/null +++ b/random-xkcd/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + + #485D92 + #FFFFFF + #DAE2FF + #001847 + + #FFFFFF + #1B1B1F + diff --git a/random-xkcd/src/main/res/values/strings.xml b/random-xkcd/src/main/res/values/strings.xml new file mode 100644 index 0000000..79ce8d7 --- /dev/null +++ b/random-xkcd/src/main/res/values/strings.xml @@ -0,0 +1,23 @@ + + + Random XKCD + + XKCD + + Loading… + Could not load a comic — connect to the internet and tap to retry. + Tap → new · 2-tap → URL · 3-tap → image + + URL copied: %1$s + Comic image copied to clipboard + Could not copy image — tap to load a comic first + Could not load comic — check your connection + + #%1$d “%2$s” + alt: %1$s + + + Comics © Randall Munroe · xkcd.com · CC BY-NC 2.5 + diff --git a/random-xkcd/src/main/res/values/styles.xml b/random-xkcd/src/main/res/values/styles.xml new file mode 100644 index 0000000..17bb8c5 --- /dev/null +++ b/random-xkcd/src/main/res/values/styles.xml @@ -0,0 +1,9 @@ + + + + From 57cf54a9a5347f42bf6901696835f8871894e9d4 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 15:01:50 +0200 Subject: [PATCH 04/16] random-xkcd: tap classifier for single / double / triple taps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small purpose-built tap-burst state machine and wires it into the Fragment's touch listener. Android's `GestureDetector` resolves single + double taps but not triple, and the XKCD plugin needs all three (single = new comic, double = copy URL, triple = copy image), so a hand-rolled classifier reads cleaner than a hybrid. `TapCountClassifier` is intentionally pure — no clocks, no `Handler`, no Android imports. The Fragment supplies `now` (uptime millis) and decides when to call `resolve()`. That makes the classifier unit-testable in plain JUnit (see `TapCountClassifierTest`). The touch listener filters out scrolls before counting taps: an `ACTION_DOWN`/`ACTION_UP` pair only feeds the classifier if the finger moved less than the system touch slop. Returning `false` from the listener avoids consuming the event, so the ScrollView keeps its scroll behavior on tall comics. The classifier's three outputs are routed through `handleClassification`, which is wired to no-ops for now — subsequent commits replace the no-ops with `loadRandomComic`, `copyUrlToClipboard`, and `copyImageToClipboard` respectively. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../xkcdrandom/fragments/XkcdPanelFragment.kt | 89 ++++++++++++++- .../xkcdrandom/ui/TapCountClassifier.kt | 87 ++++++++++++++ .../xkcdrandom/ui/TapCountClassifierTest.kt | 107 ++++++++++++++++++ 3 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifier.kt create mode 100644 random-xkcd/src/test/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifierTest.kt diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt index 88556b5..92de2c9 100644 --- a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt @@ -1,8 +1,13 @@ package com.codeonthego.xkcdrandom.fragments import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.SystemClock import android.view.LayoutInflater +import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView @@ -11,17 +16,29 @@ import android.widget.TextView import androidx.fragment.app.Fragment import com.codeonthego.xkcdrandom.R import com.codeonthego.xkcdrandom.XkcdRandomPlugin +import com.codeonthego.xkcdrandom.ui.TapCountClassifier import com.itsaky.androidide.plugins.base.PluginFragmentHelper /** - * The "XKCD" tab body — shell. + * The "XKCD" tab body. * - * This commit lays out the Fragment + view bindings + render - * primitives. Behavior (tap handling, network fetch, clipboard) lands - * in subsequent commits. + * Reading order: + * - onCreateView / onViewCreated: standard fragment setup, with the + * PluginFragmentHelper-wrapped inflater that lets us resolve our + * own R.layout.* against the plugin's APK. + * - the OnTouchListener: where ACTION_UP feeds the + * [TapCountClassifier] and decides what to do next. + * + * Why we don't use [android.view.GestureDetector]: + * - GestureDetector resolves single/double tap but not triple. + * - A small purpose-built [TapCountClassifier] reads cleaner in the + * Tier-3 walkthrough. */ class XkcdPanelFragment : Fragment() { + private val tapClassifier = TapCountClassifier() + private val mainHandler = Handler(Looper.getMainLooper()) + // Bound view references — populated in onViewCreated, cleared in // onDestroyView so we don't leak views across configuration changes. private var imageCard: FrameLayout? = null @@ -31,6 +48,9 @@ class XkcdPanelFragment : Fragment() { private var progressView: ProgressBar? = null private var emptyView: TextView? = null + /** Pending tap-window timeout — cancelled if we resolve early. */ + private val resolveBurstRunnable = Runnable { handleClassification(tapClassifier.resolve()) } + override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater { // Plugins must wrap the inflater so R.layout.* resolves against // the plugin's APK resources, not the host IDE's. Without this @@ -55,12 +75,38 @@ class XkcdPanelFragment : Fragment() { progressView = view.findViewById(R.id.xkcd_progress) emptyView = view.findViewById(R.id.xkcd_empty) - // For now, start in the empty state — fetching arrives in a later - // commit that adds the network client. + // Tap dispatch: ACTION_UP feeds the classifier *only* if the + // gesture didn't move beyond the system touch slop — otherwise + // every fling/scroll on a tall comic would also fire a tap. + val root = view.findViewById(R.id.xkcd_root) + val touchSlop = ViewConfiguration.get(view.context).scaledTouchSlop + var downX = 0f + var downY = 0f + 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() // accessibility-friendly + } + } + } + // We never consume the event here; let the ScrollView keep + // its scroll behavior so long content is still scrollable. + false + } + showEmptyState() } override fun onDestroyView() { + mainHandler.removeCallbacks(resolveBurstRunnable) imageCard = null imageView = null captionView = null @@ -70,6 +116,37 @@ class XkcdPanelFragment : Fragment() { super.onDestroyView() } + // --- gesture handling --- + + private fun handleTap() { + val now = SystemClock.uptimeMillis() + val burstClosedEarly = tapClassifier.onTap(now) + if (burstClosedEarly) { + // Triple-tap: resolve immediately for snappy feedback. + mainHandler.removeCallbacks(resolveBurstRunnable) + handleClassification(tapClassifier.resolve()) + return + } + // Otherwise, wait one window for more taps. Re-arm the timeout + // on every tap so the burst only fires after the user pauses. + mainHandler.removeCallbacks(resolveBurstRunnable) + mainHandler.postDelayed(resolveBurstRunnable, TapCountClassifier.DEFAULT_WINDOW_MS) + } + + private fun handleClassification(c: TapCountClassifier.Classification?) { + // Guard against the deferred Handler runnable firing after the + // view has been torn down — would otherwise touch viewLifecycleOwner. + if (!isAdded || view == null) return + // Wiring to actual behaviors (fetch / clipboard) arrives in + // subsequent commits. For now, every classification is a no-op. + when (c) { + TapCountClassifier.Classification.SINGLE -> { /* loadRandomComic — later */ } + TapCountClassifier.Classification.DOUBLE -> { /* copyUrlToClipboard — later */ } + TapCountClassifier.Classification.TRIPLE -> { /* copyImageToClipboard — later */ } + null -> { /* nothing to do */ } + } + } + // --- rendering --- private fun showEmptyState() { diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifier.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifier.kt new file mode 100644 index 0000000..5d6bd45 --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifier.kt @@ -0,0 +1,87 @@ +package com.codeonthego.xkcdrandom.ui + +/** + * Tiny state machine that turns a stream of taps into one of three + * classifications: SINGLE / DOUBLE / TRIPLE. + * + * Why we don't reuse Android's [android.view.GestureDetector]: + * - GestureDetector exposes onSingleTapConfirmed + onDoubleTap, but + * no onTripleTap. The ticket explicitly calls for triple-tap. + * - A 25-line state machine reads more clearly in the demo's Tier-3 + * walkthrough than rolling extra logic on top of GestureDetector. + * + * Contract: + * - taps within [windowMillis] of each other accumulate + * - the FIRST tap arms a TIMEOUT (host fragment posts a delayed + * callback to call [resolve] after [windowMillis]) + * - additional taps before that deadline reset the deadline (every + * tap extends the window) + * - on the deadline OR on a fourth tap, [resolve] returns the + * classification and the state resets + * - 4+ taps clamp to TRIPLE — we don't want to silently drop them. + * + * The class itself is pure (no clocks, no Handler) — the host fragment + * supplies the "now" via [onTap]'s default param and decides when to + * call [resolve]. That's what makes it unit-testable in plain JUnit + * without Robolectric. + */ +class TapCountClassifier( + /** Inter-tap window: a tap within this many ms of the prior tap counts as part of the burst. */ + private val windowMillis: Long = DEFAULT_WINDOW_MS, +) { + enum class Classification { SINGLE, DOUBLE, TRIPLE } + + private var count: Int = 0 + private var lastTapAt: Long = 0L + + /** + * Record a tap. If the tap is within [windowMillis] of the previous + * tap, it extends the burst; otherwise it starts a new burst (and + * the host should treat any pending unresolved burst as expired — + * [resolve] handles that idempotently). + * + * Returns true if this tap closed the burst (3+ taps reached the + * triple-tap clamp), so the caller can resolve immediately instead + * of waiting for the timeout. Returns false if more taps could + * still arrive within the window. + */ + fun onTap(now: Long): Boolean { + if (count == 0 || now - lastTapAt > windowMillis) { + // New burst — either this is the first tap, or the previous + // burst has timed out and was never resolved (resolve() + // missed; we recover gracefully). + count = 1 + } else { + count++ + } + lastTapAt = now + // 3+ taps clamp to TRIPLE. Resolve eagerly so the user gets + // immediate feedback instead of waiting out the window. + return count >= 3 + } + + /** + * Resolve the current burst into a classification. Returns null if + * no taps have happened (defensive — callers usually only call + * this after at least one onTap or via a timeout). After resolving, + * the state is reset for the next burst. + */ + fun resolve(): Classification? { + val c = count + count = 0 + lastTapAt = 0L + return when { + c <= 0 -> null + c == 1 -> Classification.SINGLE + c == 2 -> Classification.DOUBLE + else -> Classification.TRIPLE // 3+ clamps to TRIPLE + } + } + + /** True iff a burst is in progress. Host uses this to know whether to schedule a timeout. */ + fun hasPendingBurst(): Boolean = count > 0 + + companion object { + const val DEFAULT_WINDOW_MS: Long = 300L + } +} diff --git a/random-xkcd/src/test/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifierTest.kt b/random-xkcd/src/test/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifierTest.kt new file mode 100644 index 0000000..e784075 --- /dev/null +++ b/random-xkcd/src/test/kotlin/com/codeonthego/xkcdrandom/ui/TapCountClassifierTest.kt @@ -0,0 +1,107 @@ +package com.codeonthego.xkcdrandom.ui + +import com.codeonthego.xkcdrandom.ui.TapCountClassifier.Classification +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the tap-count state machine. Pure JUnit — no Robolectric, + * no Android dependencies. Synthetic timestamps so we control "now" exactly. + */ +class TapCountClassifierTest { + + @Test + fun `single tap resolves to SINGLE`() { + val c = TapCountClassifier(windowMillis = 300L) + assertFalse(c.onTap(now = 1000L)) + assertEquals(Classification.SINGLE, c.resolve()) + } + + @Test + fun `two fast taps resolve to DOUBLE`() { + val c = TapCountClassifier(windowMillis = 300L) + assertFalse(c.onTap(now = 1000L)) + assertFalse(c.onTap(now = 1100L)) // 100ms later, within window + assertEquals(Classification.DOUBLE, c.resolve()) + } + + @Test + fun `slow second tap starts a new burst (treated as two singles)`() { + val c = TapCountClassifier(windowMillis = 300L) + c.onTap(now = 1000L) + assertEquals(Classification.SINGLE, c.resolve()) // first burst resolves + c.onTap(now = 2000L) // 1s later — new burst + assertEquals(Classification.SINGLE, c.resolve()) + } + + @Test + fun `triple tap returns true on the third onTap (resolve early)`() { + val c = TapCountClassifier(windowMillis = 300L) + assertFalse(c.onTap(now = 1000L)) + assertFalse(c.onTap(now = 1100L)) + assertTrue(c.onTap(now = 1200L)) // third tap closes the burst + assertEquals(Classification.TRIPLE, c.resolve()) + } + + @Test + fun `four taps clamp to TRIPLE`() { + val c = TapCountClassifier(windowMillis = 300L) + c.onTap(now = 1000L) + c.onTap(now = 1100L) + c.onTap(now = 1200L) + // Even though the third tap eagerly closes the burst, a fourth + // tap before the host has resolved must not crash and must not + // upgrade past TRIPLE. + c.onTap(now = 1300L) + assertEquals(Classification.TRIPLE, c.resolve()) + } + + @Test + fun `tap after the window starts a new burst`() { + val c = TapCountClassifier(windowMillis = 300L) + c.onTap(now = 1000L) + // Exactly at the boundary (now - last == window) → still within + // the window, since the predicate is `> window`. One ms past + // is the first that starts a new burst. + c.onTap(now = 1301L) + assertEquals(Classification.SINGLE, c.resolve()) + } + + @Test + fun `tap exactly at the window boundary still extends the burst`() { + val c = TapCountClassifier(windowMillis = 300L) + c.onTap(now = 1000L) + c.onTap(now = 1300L) // now - last == window → still inside + assertEquals(Classification.DOUBLE, c.resolve()) + } + + @Test + fun `resolve with no taps returns null`() { + val c = TapCountClassifier(windowMillis = 300L) + assertNull(c.resolve()) + } + + @Test + fun `resolve resets state for next burst`() { + val c = TapCountClassifier(windowMillis = 300L) + c.onTap(now = 1000L) + c.onTap(now = 1100L) + assertEquals(Classification.DOUBLE, c.resolve()) + // After resolve, next tap should be a fresh SINGLE. + c.onTap(now = 5000L) + assertEquals(Classification.SINGLE, c.resolve()) + } + + @Test + fun `hasPendingBurst flips correctly`() { + val c = TapCountClassifier(windowMillis = 300L) + assertFalse(c.hasPendingBurst()) + c.onTap(now = 1000L) + assertTrue(c.hasPendingBurst()) + c.resolve() + assertFalse(c.hasPendingBurst()) + } +} From 96b4944f180637e4bc7e6a94b21170ed16d5d90d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 15:02:48 +0200 Subject: [PATCH 05/16] random-xkcd: fetch a comic over HTTPS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small OkHttp-based client and wires it into the Fragment's single-tap path. The whole network surface fits in one file: XkcdApiClient.fetchLatest() / fetchByNumber(num) / openImageStream() fetchRandom() picks a number in [1, latestNum] and keeps picking until one returns a real comic. The only way it returns null is if the initial "latest comic" probe fails (network down). xkcd #404 is the joke "page not found" comic that returns HTTP 404 on its JSON endpoint, so we loop past it rather than bound retries — the loop converges in 1–2 picks on a healthy network and never gives up while the network works. Defensive parsing in parseComic() rejects any image URL that isn't `https://`, so a future MITM that swaps in `http://` doesn't break the plugin's HTTPS-only claim. Fragment wiring: - `loadRandomComic()` runs the fetch + decode on Dispatchers.IO and shows the result via lifecycleScope's Main dispatcher. - Rapid single-taps no-op while a previous fetch is in flight. - Read is bounded to 5 MB with cooperative cancellation so the coroutine respects lifecycleScope teardown mid-download. - Plain `BitmapFactory.decodeByteArray(...)` for now; for very large images on low-end devices, see Android's bounded-bitmap-decoding pattern at https://developer.android.com/topic/performance/graphics/load-bitmap Adds OkHttp 4.12.0 as the only new build dependency. Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/build.gradle.kts | 5 + .../xkcdrandom/fragments/XkcdPanelFragment.kt | 124 ++++++++++++++++-- .../xkcdrandom/net/XkcdApiClient.kt | 109 +++++++++++++++ .../codeonthego/xkcdrandom/net/XkcdComic.kt | 19 +++ 4 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt create mode 100644 random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdComic.kt diff --git a/random-xkcd/build.gradle.kts b/random-xkcd/build.gradle.kts index 6e95a16..4951721 100644 --- a/random-xkcd/build.gradle.kts +++ b/random-xkcd/build.gradle.kts @@ -70,6 +70,11 @@ dependencies { 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") } diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt index 92de2c9..6469c1a 100644 --- a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt @@ -1,5 +1,6 @@ package com.codeonthego.xkcdrandom.fragments +import android.graphics.BitmapFactory import android.os.Bundle import android.os.Handler import android.os.Looper @@ -13,11 +14,22 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView +import android.widget.Toast import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import com.codeonthego.xkcdrandom.R import com.codeonthego.xkcdrandom.XkcdRandomPlugin +import com.codeonthego.xkcdrandom.net.XkcdApiClient +import com.codeonthego.xkcdrandom.net.XkcdComic import com.codeonthego.xkcdrandom.ui.TapCountClassifier import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext +import java.io.ByteArrayOutputStream /** * The "XKCD" tab body. @@ -27,15 +39,12 @@ import com.itsaky.androidide.plugins.base.PluginFragmentHelper * PluginFragmentHelper-wrapped inflater that lets us resolve our * own R.layout.* against the plugin's APK. * - the OnTouchListener: where ACTION_UP feeds the - * [TapCountClassifier] and decides what to do next. - * - * Why we don't use [android.view.GestureDetector]: - * - GestureDetector resolves single/double tap but not triple. - * - A small purpose-built [TapCountClassifier] reads cleaner in the - * Tier-3 walkthrough. + * [TapCountClassifier] and decides to roll a new comic. + * - loadRandomComic(): coroutine-based fetch + render. */ class XkcdPanelFragment : Fragment() { + private val api = XkcdApiClient() private val tapClassifier = TapCountClassifier() private val mainHandler = Handler(Looper.getMainLooper()) @@ -48,6 +57,12 @@ class XkcdPanelFragment : Fragment() { private var progressView: ProgressBar? = null private var emptyView: TextView? = null + /** The comic we're currently displaying. */ + private var currentComic: XkcdComic? = null + + /** In-flight fetch, so rapid SINGLE-tap bursts don't fan out into N parallel fetches. */ + private var loadJob: Job? = null + /** Pending tap-window timeout — cancelled if we resolve early. */ private val resolveBurstRunnable = Runnable { handleClassification(tapClassifier.resolve()) } @@ -102,7 +117,10 @@ class XkcdPanelFragment : Fragment() { false } - showEmptyState() + // First show → kick off a fresh fetch. On configuration change + // (rotation, etc.) the fragment is recreated but we don't re-fetch; + // the user can tap to roll a new comic if they want. + if (savedInstanceState == null) loadRandomComic() } override fun onDestroyView() { @@ -137,18 +155,25 @@ class XkcdPanelFragment : Fragment() { // Guard against the deferred Handler runnable firing after the // view has been torn down — would otherwise touch viewLifecycleOwner. if (!isAdded || view == null) return - // Wiring to actual behaviors (fetch / clipboard) arrives in - // subsequent commits. For now, every classification is a no-op. when (c) { - TapCountClassifier.Classification.SINGLE -> { /* loadRandomComic — later */ } - TapCountClassifier.Classification.DOUBLE -> { /* copyUrlToClipboard — later */ } - TapCountClassifier.Classification.TRIPLE -> { /* copyImageToClipboard — later */ } + TapCountClassifier.Classification.SINGLE -> loadRandomComic() + TapCountClassifier.Classification.DOUBLE -> { /* copyUrlToClipboard — next commit */ } + TapCountClassifier.Classification.TRIPLE -> { /* copyImageToClipboard — next commit */ } null -> { /* nothing to do */ } } } + private fun toast(text: String) { + Toast.makeText(requireContext(), text, Toast.LENGTH_SHORT).show() + } + // --- rendering --- + private fun showLoading() { + progressView?.visibility = View.VISIBLE + emptyView?.visibility = View.GONE + } + private fun showEmptyState() { progressView?.visibility = View.GONE imageCard?.visibility = View.GONE @@ -157,4 +182,79 @@ class XkcdPanelFragment : Fragment() { emptyView?.visibility = View.VISIBLE emptyView?.setText(R.string.empty_offline) } + + private fun showComic(comic: XkcdComic, bmp: android.graphics.Bitmap) { + progressView?.visibility = View.GONE + emptyView?.visibility = View.GONE + imageCard?.visibility = View.VISIBLE + imageView?.setImageBitmap(bmp) + captionView?.apply { + visibility = View.VISIBLE + text = getString(R.string.comic_caption, comic.num, comic.title) + } + altView?.apply { + visibility = View.VISIBLE + text = getString(R.string.comic_alt_prefix, comic.alt) + } + } + + // --- networking --- + + /** + * Fetch a new random comic, then update the UI. All network IO and + * bitmap decoding are on Dispatchers.IO; the callback hops back to + * the main thread via the lifecycleScope's Main dispatcher. + * + * Skips if a previous fetch is still in flight (rapid taps no-op). + */ + private fun loadRandomComic() { + if (loadJob?.isActive == true) return + if (currentComic == null) showLoading() + loadJob = viewLifecycleOwner.lifecycleScope.launch { + val result = withContext(Dispatchers.IO) { fetchAndDecode() } + val (comic, bmp) = result ?: run { + if (currentComic == null) showEmptyState() else { + progressView?.visibility = View.GONE + toast(getString(R.string.toast_fetch_failed)) + } + return@launch + } + currentComic = comic + showComic(comic, bmp) + } + } + + /** Returns (comic, decoded bitmap) on success, null on any IO/parse failure. */ + private suspend fun fetchAndDecode(): Pair? { + val comic = api.fetchRandom() ?: return null + val bytes = api.openImageStream(comic.imageUrl)?.use { stream -> + // Bounded read — cap at 5 MB so a pathological response can't + // OOM the decoder. xkcd images are far below this in practice. + val out = ByteArrayOutputStream() + val buf = ByteArray(8 * 1024) + var total = 0 + while (true) { + // Cooperative cancellation — let lifecycleScope teardown + // interrupt mid-download. + coroutineContext.ensureActive() + val n = stream.read(buf) + if (n < 0) break + total += n + if (total > MAX_IMAGE_BYTES) return null + out.write(buf, 0, n) + } + out.toByteArray() + } ?: return null + // Plain decode. For very large images on low-end devices, Android's + // bounded-bitmap-decoding pattern (BitmapFactory.Options.inSampleSize) + // is the production-grade approach — see + // https://developer.android.com/topic/performance/graphics/load-bitmap + val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null + return comic to bmp + } + + companion object { + /** 5 MB cap — comfortably above the largest xkcd PNG, but bounded. */ + const val MAX_IMAGE_BYTES = 5 * 1024 * 1024 + } } diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt new file mode 100644 index 0000000..44f28e0 --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt @@ -0,0 +1,109 @@ +package com.codeonthego.xkcdrandom.net + +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONObject +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.TimeUnit +import kotlin.random.Random + +/** + * The whole xkcd network surface, in one file so the example reads + * top-to-bottom. Two endpoints: + * - GET https://xkcd.com/info.0.json → latest comic + * - GET https://xkcd.com//info.0.json → specific comic + * + * No auth, no rate-limit headers, no pagination. We keep this client + * dependency-light: OkHttp + the org.json reader that ships with Android. + */ +class XkcdApiClient( + private val client: OkHttpClient = defaultClient(), +) { + /** + * Fetch a random comic. Picks a number in [1, latestNum] and keeps + * picking until one returns a real comic. The only way this returns + * null is if the initial "latest comic" probe fails — i.e. the + * network is down. Once we know the network works, we loop until + * a non-404 number comes back. + * + * Why the loop is unbounded: xkcd #404 returns HTTP 404 on its JSON + * endpoint (the "page not found" joke comic), so a single retry can + * still land on the same dud. Looping until success is simpler than + * a retry budget + fallback — and on a healthy network it converges + * in 1-2 picks. + */ + 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("https://xkcd.com/info.0.json")?.let(::parseComic) + + fun fetchByNumber(num: Int): XkcdComic? = + getJson("https://xkcd.com/$num/info.0.json")?.let(::parseComic) + + /** + * Stream the comic's PNG. Caller must close the returned stream. + * Returns null if the request failed, the response body was empty, + * or any IO error occurred (timeout, DNS, TLS). Returning null — + * rather than throwing — keeps the caller's empty-state branch + * reachable; without it the spinner can hang on a flaky connection. + */ + fun openImageStream(imageUrl: String): InputStream? = try { + val response = client.newCall(Request.Builder().url(imageUrl).build()).execute() + if (!response.isSuccessful) { + response.close() + null + } else { + // body() can be null on 204 etc. — for xkcd it shouldn't, but + // handle the case explicitly. + response.body?.byteStream() + } + } catch (_: IOException) { + null + } + + private fun getJson(url: String): JSONObject? = try { + val response = client.newCall(Request.Builder().url(url).build()).execute() + response.use { + if (!it.isSuccessful) null + else it.body?.string()?.let(::JSONObject) + } + } catch (_: IOException) { + // Network / DNS / TLS failure → behave the same as "comic + // unavailable". Caller falls back to the offline empty state. + null + } + + private fun parseComic(obj: JSONObject): XkcdComic? = try { + val img = obj.getString("img") + // Defensive parsing: reject anything that isn't an https:// URL. + // The Tier-3 tooltip claims "fetches use HTTPS only"; enforcing the + // claim here protects against a future MITM that swaps in http:// or + // a non-URL scheme. Returning null routes through the same "comic + // unavailable" fallback as a network failure. + if (!img.startsWith("https://")) return null + XkcdComic( + num = obj.getInt("num"), + title = obj.optString("safe_title", obj.optString("title", "")), + alt = obj.optString("alt", ""), + imageUrl = img, + ) + } catch (_: Exception) { + // Malformed payload → null, treated as a fetch failure. + null + } + + companion object { + private fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(15, TimeUnit.SECONDS) + .build() + } +} diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdComic.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdComic.kt new file mode 100644 index 0000000..3992e6a --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdComic.kt @@ -0,0 +1,19 @@ +package com.codeonthego.xkcdrandom.net + +/** + * One xkcd comic. Mirrors the subset of fields we use from + * https://xkcd.com/info.0.json. + * + * Kept as a plain data class with a hand-written JSON reader (see + * [XkcdApiClient.parseComic]) so this plugin doesn't pull in a JSON + * library — keeps the dependency graph small and the example readable. + */ +data class XkcdComic( + val num: Int, + val title: String, + val alt: String, + val imageUrl: String, +) { + /** Canonical URL for sharing (`https://xkcd.com//`). */ + val pageUrl: String get() = "https://xkcd.com/$num/" +} From b54ab998519595043c6281807607706922f5ba0f Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 15:03:49 +0200 Subject: [PATCH 06/16] =?UTF-8?q?random-xkcd:=20clipboard=20=E2=80=94=20UR?= =?UTF-8?q?L=20on=20double-tap,=20image=20on=20triple-tap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires DOUBLE and TRIPLE tap classifications to clipboard handlers and buffers the last comic's PNG bytes in memory so triple-tap can copy the image without re-downloading or re-encoding the rendered Bitmap. Text — the easy path: `ClipboardManager.setPrimaryClip(ClipData.newPlainText(...))` needs no permission. Android doesn't gate clipboard with a `` either; foreground apps (which a plugin always is) can write freely. Image — the FileProvider hop: 1. Plugin `` declarations in the manifest are dead code — plugins are DexClassLoader-loaded, not installed as Android apps, so PackageManager never registers them. The escape valve is to route through the host IDE's existing FileProvider authority. 2. The host's file_provider_paths.xml exposes filesDir, so we write the buffered PNG to ctx.filesDir/xkcd_share/last.png and ask FileProvider.getUriForFile(ctx, "${ctx.packageName}.providers.fileprovider", target) for a content URI. 3. `ClipData.newUri` queries the ContentResolver for the URI's MIME type — .png resolves to image/png, so paste targets see image/* and offer to paste the image, not the URL string. Toasts on every action so the user can tell the copy happened — the system clipboard is invisible without feedback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../xkcdrandom/fragments/XkcdPanelFragment.kt | 99 +++++++++++++++++-- 1 file changed, 91 insertions(+), 8 deletions(-) diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt index 6469c1a..a44c031 100644 --- a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt @@ -1,5 +1,8 @@ package com.codeonthego.xkcdrandom.fragments +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context import android.graphics.BitmapFactory import android.os.Bundle import android.os.Handler @@ -15,6 +18,7 @@ import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import android.widget.Toast +import androidx.core.content.FileProvider import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import com.codeonthego.xkcdrandom.R @@ -30,6 +34,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlin.coroutines.coroutineContext import java.io.ByteArrayOutputStream +import java.io.File /** * The "XKCD" tab body. @@ -39,8 +44,14 @@ import java.io.ByteArrayOutputStream * PluginFragmentHelper-wrapped inflater that lets us resolve our * own R.layout.* against the plugin's APK. * - the OnTouchListener: where ACTION_UP feeds the - * [TapCountClassifier] and decides to roll a new comic. + * [TapCountClassifier] and decides to roll a new comic / copy URL / + * copy image. * - loadRandomComic(): coroutine-based fetch + render. + * + * Why we don't use [android.view.GestureDetector]: + * - GestureDetector resolves single/double tap but not triple. + * - A small purpose-built [TapCountClassifier] reads cleaner in the + * Tier-3 walkthrough. */ class XkcdPanelFragment : Fragment() { @@ -57,9 +68,16 @@ class XkcdPanelFragment : Fragment() { private var progressView: ProgressBar? = null private var emptyView: TextView? = null - /** The comic we're currently displaying. */ + /** The comic we're currently displaying — used by the clipboard handlers. */ private var currentComic: XkcdComic? = null + /** + * Raw PNG bytes of the currently-displayed comic. Kept in memory so a + * triple-tap can copy the image to the clipboard without re-downloading + * or re-encoding the rendered Bitmap. + */ + private var lastBytes: ByteArray? = null + /** In-flight fetch, so rapid SINGLE-tap bursts don't fan out into N parallel fetches. */ private var loadJob: Job? = null @@ -157,12 +175,74 @@ class XkcdPanelFragment : Fragment() { if (!isAdded || view == null) return when (c) { TapCountClassifier.Classification.SINGLE -> loadRandomComic() - TapCountClassifier.Classification.DOUBLE -> { /* copyUrlToClipboard — next commit */ } - TapCountClassifier.Classification.TRIPLE -> { /* copyImageToClipboard — next commit */ } + TapCountClassifier.Classification.DOUBLE -> copyUrlToClipboard() + TapCountClassifier.Classification.TRIPLE -> copyImageToClipboard() null -> { /* nothing to do */ } } } + // --- clipboard --- + + private fun copyUrlToClipboard() { + val comic = currentComic ?: return + val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(ClipData.newPlainText("xkcd-url", comic.pageUrl)) + toast(getString(R.string.toast_url_copied, comic.pageUrl)) + } + + /** + * Triple-tap → push the in-memory PNG to the clipboard as image/png. + * + * Plugin manifest providers don't get registered at runtime (plugins + * are loaded via DexClassLoader, not installed as apps), so we route + * through the host IDE's existing FileProvider authority. The host's + * file_provider_paths.xml exposes `filesDir` (``), + * so we write the current comic's PNG to `filesDir/xkcd_share/last.png` + * and grant a content URI from there. + * + * URI-permission caveat: `ClipData.newUri` does not auto-grant + * `FLAG_GRANT_READ_URI_PERMISSION` to the eventual paste target. We + * rely on the host's FileProvider declaring `grantUriPermissions="true"`, + * which lets the system grant a temporary read grant to whatever app + * calls `ContentResolver.openInputStream` on our clip URI. This works on + * stock Android API 24+ but has been observed to fail silently on some + * OEM clipboard managers — worth real-device verification. + */ + private fun copyImageToClipboard() { + val bytes = lastBytes + if (bytes == null) { + toast(getString(R.string.toast_image_copy_failed)) + return + } + val ctx = requireContext() + viewLifecycleOwner.lifecycleScope.launch { + // Up to 5 MB of file write — off the main thread. + 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 + } + } + if (target == null) { + toast(getString(R.string.toast_image_copy_failed)) + return@launch + } + val authority = "${ctx.packageName}.providers.fileprovider" + val uri = FileProvider.getUriForFile(ctx, authority, target) + // ClipData.newUri queries the ContentResolver for the URI's + // MIME type (image/png for our PNG), so the resulting clip + // advertises image/* to paste targets. + val clip = ClipData.newUri(ctx.contentResolver, "xkcd-image", uri) + val cm = ctx.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + cm.setPrimaryClip(clip) + toast(getString(R.string.toast_image_copied)) + } + } + private fun toast(text: String) { Toast.makeText(requireContext(), text, Toast.LENGTH_SHORT).show() } @@ -209,10 +289,12 @@ class XkcdPanelFragment : Fragment() { */ private fun loadRandomComic() { if (loadJob?.isActive == true) return + // Only blank the panel if we have nothing to show yet — otherwise + // keep the current comic visible while the new one loads. if (currentComic == null) showLoading() loadJob = viewLifecycleOwner.lifecycleScope.launch { val result = withContext(Dispatchers.IO) { fetchAndDecode() } - val (comic, bmp) = result ?: run { + val (comic, bytes, bmp) = result ?: run { if (currentComic == null) showEmptyState() else { progressView?.visibility = View.GONE toast(getString(R.string.toast_fetch_failed)) @@ -220,12 +302,13 @@ class XkcdPanelFragment : Fragment() { return@launch } currentComic = comic + lastBytes = bytes showComic(comic, bmp) } } - /** Returns (comic, decoded bitmap) on success, null on any IO/parse failure. */ - private suspend fun fetchAndDecode(): Pair? { + /** Returns (comic, raw PNG bytes, decoded bitmap) on success, null on any IO/parse failure. */ + private suspend fun fetchAndDecode(): Triple? { val comic = api.fetchRandom() ?: return null val bytes = api.openImageStream(comic.imageUrl)?.use { stream -> // Bounded read — cap at 5 MB so a pathological response can't @@ -250,7 +333,7 @@ class XkcdPanelFragment : Fragment() { // is the production-grade approach — see // https://developer.android.com/topic/performance/graphics/load-bitmap val bmp = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null - return comic to bmp + return Triple(comic, bytes, bmp) } companion object { From 605113d8254b214c0962abdbe130d4b08d1ec4ce Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 15:04:38 +0200 Subject: [PATCH 07/16] random-xkcd: three-tier tooltip + Tier-3 docs walkthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opts the plugin class into `DocumentationExtension` and registers CoGo's per-plugin help API: Tier 1 (summary) — one-liner shown when long-pressing the tab Tier 2 (detail) — HTML paragraph revealed by "See More" Tier 3 (button) — full HTML walkthrough page served at http://localhost:6174/plugin// `getTier3DocsAssetPath() = "docs"` tells the host to walk `src/main/assets/docs/` at install time and serve each file (CSS + HTML) at the URL above; the asset bundle's own files reference each other with relative paths. The walkthrough page itself is the canonical "how to write a CoGo plugin" example for this codebase — a 7-step tour aligned with the commit history. Plugin Manager icons added too: day + night variants under src/main/assets/, declared via `plugin.icon_day` / `plugin.icon_night` meta-data. Required for debug-built `.cgp`s; release builds skip the check. Includes `plugin.description` for the Plugin Manager listing. Includes xkcd attribution. xkcd comics are © Randall Munroe and licensed CC BY-NC 2.5 (https://xkcd.com/license.html). The Tier-3 page surfaces this; the plugin's in-panel UI already shows a visible attribution line per the previous commit's strings. Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/src/main/AndroidManifest.xml | 15 + .../src/main/assets/docs/css/walkthrough.css | 71 +++ random-xkcd/src/main/assets/docs/index.html | 488 ++++++++++++++++++ random-xkcd/src/main/assets/icon_day.png | Bin 0 -> 1489 bytes random-xkcd/src/main/assets/icon_night.png | Bin 0 -> 1486 bytes .../xkcdrandom/XkcdRandomPlugin.kt | 68 ++- 6 files changed, 638 insertions(+), 4 deletions(-) create mode 100644 random-xkcd/src/main/assets/docs/css/walkthrough.css create mode 100644 random-xkcd/src/main/assets/docs/index.html create mode 100644 random-xkcd/src/main/assets/icon_day.png create mode 100644 random-xkcd/src/main/assets/icon_night.png diff --git a/random-xkcd/src/main/AndroidManifest.xml b/random-xkcd/src/main/AndroidManifest.xml index 80a28bf..f88a914 100644 --- a/random-xkcd/src/main/AndroidManifest.xml +++ b/random-xkcd/src/main/AndroidManifest.xml @@ -18,6 +18,10 @@ android:name="plugin.version" android:value="${pluginVersion}" /> + + @@ -42,6 +46,17 @@ android:name="plugin.main_class" android:value="com.codeonthego.xkcdrandom.XkcdRandomPlugin" /> + + + + diff --git a/random-xkcd/src/main/assets/docs/css/walkthrough.css b/random-xkcd/src/main/assets/docs/css/walkthrough.css new file mode 100644 index 0000000..768b45f --- /dev/null +++ b/random-xkcd/src/main/assets/docs/css/walkthrough.css @@ -0,0 +1,71 @@ +/* Tier 3 walkthrough styles — kept tiny so the doc loads instantly even + * on slow devices. Dark-mode friendly via CSS color-scheme + media + * query. No external resources / fonts. + */ + +:root { + color-scheme: light dark; + --bg: #ffffff; + --fg: #1b1b1f; + --muted: #5e5e6c; + --accent: #485d92; + --code-bg: #f5f6fa; + --code-fg: #1b1b1f; + --comment: #6a737d; + --kw: #c792ea; + --str: #c3e88d; + --border: #e3e3ea; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #121215; + --fg: #e6e1e5; + --muted: #b8b8c0; + --accent: #b1c5ff; + --code-bg: #1a1a1f; + --code-fg: #e6e1e5; + --comment: #7a8290; + --border: #303038; + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--fg); + line-height: 1.55; + padding: 24px 20px 64px; + max-width: 760px; + margin: 0 auto; +} +h1 { font-size: 1.6rem; margin: 0 0 4px; } +h2 { font-size: 1.2rem; margin: 32px 0 8px; color: var(--accent); } +h3 { font-size: 1.05rem; margin: 24px 0 6px; } +p, li { font-size: 0.95rem; } +.lede { color: var(--muted); margin: 0 0 24px; } +a { color: var(--accent); } +ul { padding-left: 20px; } +hr { border: 0; border-top: 1px solid var(--border); margin: 24px 0; } + +pre { + background: var(--code-bg); + color: var(--code-fg); + padding: 12px 14px; + overflow-x: auto; + border-radius: 6px; + border: 1px solid var(--border); + font-size: 0.85rem; + line-height: 1.45; + font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace; +} +code { font-family: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace; font-size: 0.9em; } +.callout { + background: rgba(72, 93, 146, 0.08); + border-left: 3px solid var(--accent); + padding: 10px 14px; + margin: 16px 0; + border-radius: 4px; +} +.muted { color: var(--muted); font-size: 0.85rem; } diff --git a/random-xkcd/src/main/assets/docs/index.html b/random-xkcd/src/main/assets/docs/index.html new file mode 100644 index 0000000..20902aa --- /dev/null +++ b/random-xkcd/src/main/assets/docs/index.html @@ -0,0 +1,488 @@ + + + + + + XKCD Plugin — Code Walkthrough + + + + +

Building the XKCD Plugin

+

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.

+ +
+ What you'll build. An "XKCD" tab in CoGo's editor bottom + sheet. Tap the comic for a new one. Double-tap copies the + URL to the clipboard. Triple-tap copies the image. Long-press the + tab for in-IDE help. +
+ +
+ Prerequisites. Kotlin + Android fragments + Gradle + basic + `kotlinx.coroutines`. You don't need prior plugin experience — + every plugin-specific concept is called out where it shows up. +
+ +

1. Plugin entry point

+ +

Why you care: every plugin starts with an +IPlugin class that the host loads via +DexClassLoader. The class is your plugin's "main" — the +host reflectively instantiates it by the fully-qualified name in +plugin.main_class, then drives the +initialize → activate → deactivate → dispose lifecycle. +Get this skeleton right and the rest is just opt-in extensions.

+ +
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.

+ +

2. Manifest + permissions

+ +

Why you care: the manifest is how the host +discovers your plugin, finds the entry class, and validates what +you're allowed to do. Permissions in CoGo gate access to the +host's resources, not your own — which means many things +you'd expect to declare (clipboard, your own +filesDir) actually need no declaration at all.

+ +
<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:

+ + + + + + + + + + + +
PermissionWhat it gates
network.accessRequired 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.readRead access to the user's project via IdeFileService, IdeEditorService, etc.
filesystem.writeWrite access to the user's project. Same gate as above.
system.commandsRuntime.exec, process spawning.
ide.settingsRead/write IDE preferences.
project.structureWalk the user's open project.
native.codeExecute native machine code.
ide.environment.writeWrite 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.

+ +

3. The bottom-sheet tab UI

+ +

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
+        )
+    )
+}
+ +

The Fragment itself is mostly normal Android — except for one +required 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.

+ +

4. Tap interactions (single / double / triple)

+ +

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
+}
+ +

Returning false is important — consuming the event +would break scroll behavior on tall comics.

+ +

5. Network fetch over HTTPS

+ +

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
+    }
+}
+ +

Performance note: for very large +images on low-end devices, Android's bounded +bitmap decoding pattern (BitmapFactory.Options.inSampleSize +with a two-pass decode) is the production-grade approach. This plugin +keeps it simple with BitmapFactory.decodeByteArray(...) +plus a 5 MB cap on the network read.

+ +

6. Clipboard support

+ +

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.

+ +

6a. Text — the easy path

+ +
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.

+ +

6b. Image — the FileProvider hop

+ +
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.

+ +

6c. User feedback

+ +

Every clipboard action shows a toast so the user knows it +happened. The system clipboard is invisible — without feedback, +users can't tell whether a copy succeeded.

+ +

7. Three-tier tooltip for plugin help

+ +

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"
+}
+ + + + + + +
TierFieldWhat the user sees
1summaryShort string shown when long-pressing your tab.
2detailHTML rendered inside the tooltip after tapping "See More".
3buttons[].uriA button labeled description. Tapping it opens an HTML page (this one) served at http://localhost:6174/plugin/<pluginId>/<uri>.
+ +

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.

+ +

What you observe end-to-end

+ +
    +
  1. Plugin appears in Plugin Manager with status "Enabled".
  2. +
  3. "XKCD" tab shows up in the editor bottom sheet.
  4. +
  5. Open the tab → spinner → comic renders with title + alt.
  6. +
  7. Single-tap → new comic loads.
  8. +
  9. Double-tap → toast "URL copied: …" — paste in any text field to confirm.
  10. +
  11. Triple-tap → toast "Comic image copied to clipboard" — paste in Messages to confirm.
  12. +
  13. Long-press the tab → Tier-1 tooltip; "See More" → Tier-2 detail; "Code walkthrough" → opens this page.
  14. +
+ +

The sandbox model in one screen

+ +

Plugins run inside the host IDE's process, but the plugin classes +are loaded by a DexClassLoader that's separate from the +host's class loader. Three durable consequences:

+ +
    +
  1. No plugin manifest registration. The OS never sees + your plugin's AndroidManifest.xml, so + <activity>, <service>, + <provider>, and <receiver> + declarations are dead code. Anything that requires OS + registration must go through the host.
  2. + +
  3. R.* resolves against whichever + Resources your code is using. The host's + LayoutInflater resolves to host resources by default; + wrap with + PluginFragmentHelper.getPluginInflater(pluginId, parent) + to flip it to your plugin's APK.
  4. + +
  5. Permissions gate the host's resources, not your own. + Your filesDir, cacheDir, and the system + clipboard need no declaration. Declare a permission only when + you touch something the host mediates.
  6. +
+ +

The standard escape valves:

+ +
    +
  • For UI surfaces — register an extension + (UIExtension, EditorTabExtension, + DocumentationExtension).
  • +
  • For services — fetch via + context.services.get(IdeXxxService::class.java) or + requireContext().getSystemService(...).
  • +
  • For file sharing across apps — copy under + ctx.filesDir and use + FileProvider.getUriForFile(ctx, + "${ctx.packageName}.providers.fileprovider", file).
  • +
+ +

xkcd attribution + license

+ +

xkcd comics are © Randall Munroe and licensed CC BY-NC 2.5 +(see xkcd.com/license.html). +This plugin fetches comics over HTTPS, displays them with an +always-visible attribution line ("Comics © Randall Munroe · xkcd.com +· CC BY-NC 2.5" beneath every comic), and is itself non-commercial +(open-source demo plugin for an open-source IDE). No caching, no +redistribution beyond what the user explicitly copies to their own +clipboard.

+ +

The plugin's own source code is licensed per the surrounding +plugin-examples repository. xkcd's license applies only to +the comic content the plugin displays.

+ +

Where to go next

+ +
    +
  • Plugin Development Guide (wiki) — the full API + reference for every extension interface and host service.
  • +
  • Sibling demo pluginsforms-plugin and + maps-plugin show plugins with more complex UI and + persistent state.
  • +
  • This plugin's source — under 300 lines of Kotlin. + Read it top-to-bottom: XkcdRandomPlugin.kt → + XkcdPanelFragment.ktXkcdApiClient.kt → + TapCountClassifier.kt.
  • +
+ + + diff --git a/random-xkcd/src/main/assets/icon_day.png b/random-xkcd/src/main/assets/icon_day.png new file mode 100644 index 0000000000000000000000000000000000000000..0e432bb830e9900c45942614cf35039be9f3d7b8 GIT binary patch literal 1489 zcmaKs`#+O=0D!-93pMFTY0Iq*M;2vrr#SBBUPh^*d3D*e<|K1jPP(~ksqHna%UUXz zjyg(0sg?0A>GP3QYD=YZ*$xv?aXSA%KRnO#A3Oz-;elvx?t)&`w;%H(-%_$WnpM-IRch9g z4{jgbdev3H@~EM&?k}{vQu@>ysaup+Uzmd5BwkoTd}Y3QMwn?UF&~*_FTPo(4RjXB z&VDvL9Yw8h%n&&At0#u}5b45BVV*Ni`{pX!bJ8FbU1aajg=7E*I?q11KZHZ3zBJcW z)D_n*3i5v41*Ei8VB~1_ECjfeCvb1df4jE_lxi#9eOjjeZg7tYLIHxCWt--zuxb;7 zb1r!ez*}#ras@|l-dNwFr5j;uGJK$NrTR%kVQNYOG6NE1T?&N8=Z)PD?k@udy@dH? z0MeAFvyib0WN1Mz zks#^+k^w>9&Dq2v751$kr_pLWLg#udbt2|FUVu(anmRq5Ym{e4ETT{Y!BR~bwmxz* z;f*W~S(DwL(G0Vw+4N!cXFO#$gGK~Z{%n#hg#UE)u$_Z*Wd7m6P0}TFi93dixkbLa41Jx zTe71ETs$v3-fjQK#)9dkERF4JEC~JD@GrsMU}bZVfaA?g={C6b=|PNPnRUk-j~k(uSvKimS7}k4mp`57fvlKi7z>ttNAxnX)_|{Fq94q}&<4P>eq8 zHP2py+gA*uySgUN(VALAgepsf{>2z(3ZnvhDfhRw=ils}egS z9XT21nH<=&Tv1u(^QVMYllO7E3cEFKtyLD`1dcC| z47(NORhVvgx%K16Lm}Vw?wtT;TI*j@y4S8Sf2;d7fVp2)dh+>Fl5=uO$xvfFd4e>-Mvxe~|Xdx*ZebZ7m)r=FHw&u8`xkZ}D#h zUY`J*abHrwm3k2;9aM|g#kWsS-vndB? z_}f|@7vH=J3nWKNWo9O(38kuUj1>7vQh`?KOY(q^Ls?WC5>yHDBj125R=|sw^|iU26qJrrn(T$lQWq622~k%GyL)$zX)$`EmNlzNM^K1 z3L3ib!xE!|8M6R1q`oPVx-R*mE+9@uen-Y#IW;BGb^&RsK&M0~mu5VdPOm&nJ*E41 zX3w`YW@zeD-LTfi@Eh?AxK;MZLR0q!P?KwJuz`;wlS2y%S?;E~!{XOBn-|>Ehh}SH zkE`g$C-s%r%YKV9cjaO%izR5m6b`uSCp@^fSVHn6Oa3CPRV=%J4rkB@We?hN?ujp# zNYbiQXt|$?0-tC)M6g&_3MfJK<&i_$?AvyOr<1BdsIn+Bt|-K4H1eqUy}TC}<>_bLmzRHNw1a_l%fmro4r~bnV8V76M>5j<=+749S3-C@! zGmS0NBV!u`%O;h($XH_=KH)qCzX^}Gf{>g#XA#qIjKzT9>Mdi_k=l}v73UO4cP?C+lHA2~*4P|(Ta-WS{w=xK7epS>@|Fi*+VzYOY!PULg{FhVBErYp5wn9mD zn9w0~2yQ_n121EY^DZ_l36eCTEDbXa)as&^&}`kJw`|XAdat^Bzo$|4p/) + // + // The Tier 3 source lives under src/main/assets/docs/ and is + // indexed at install time by the host's Tier3AssetWalker. + + override fun getTooltipCategory(): String = "plugin_xkcd" + + override fun getTooltipEntries(): List = listOf( + PluginTooltipEntry( + tag = TOOLTIP_TAG_TAB, + summary = "Random xkcd comic. Tap to roll a new one.", + detail = """ +

This panel pulls a random comic from xkcd.com.

+
    +
  • Tap — fetch a new random comic.
  • +
  • Double-tap — copy the comic's URL to the clipboard.
  • +
  • Triple-tap — copy the comic image to the clipboard + (paste it into Messages or any image-aware app).
  • +
+

Fetches use HTTPS only.

+ """.trimIndent(), + buttons = listOf( + PluginTooltipButton( + description = "Code walkthrough", + uri = "index.html", // resolves to plugin//index.html + order = 0 + ) + ) + ) + ) + + /** + * Subdirectory under src/main/assets/ that holds the Tier 3 walkthrough. + * Every file under assets/docs/ is indexed by Tier3AssetWalker at + * install time and served from + * http://localhost:6174/plugin/com.codeonthego.xkcdrandom/ + * + * Files reference each other with relative paths (e.g. css/walkthrough.css). + */ + override fun getTier3DocsAssetPath(): String? = "docs" } From 9007574a1799fbfb2f43b9feda6804b1efd322cf Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 15:04:58 +0200 Subject: [PATCH 08/16] =?UTF-8?q?random-xkcd:=20README=20=E2=80=94=20overv?= =?UTF-8?q?iew=20+=20pointer=20to=20Tier-3=20walkthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A light source-tree README aimed at developers reading the plugin in GitHub or in their editor. The primary tutorial lives in the Tier-3 docs page (src/main/assets/docs/index.html), discoverable inside CoGo via long-press → "See More" → "Code walkthrough" on the XKCD tab. The README points readers there and surfaces: - what the plugin does in two sentences - build + test commands - the source-layout map - the xkcd attribution / CC BY-NC 2.5 license note Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/README.md | 90 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 random-xkcd/README.md 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. From 7da1231de01ddb3122a2beadd80ba294522edf62 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Tue, 12 May 2026 15:33:37 +0200 Subject: [PATCH 09/16] =?UTF-8?q?random-xkcd:=20code-review=20pass=20?= =?UTF-8?q?=E2=80=94=20host=20allowlist,=20cancellation,=20conventions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the findings from the pre-push code review. Bugfixes: - `XkcdApiClient.parseComic` — host allowlist instead of just scheme. Previously accepted any `https://...` URL from the JSON's `img` field; now restricts to `https://imgs.xkcd.com/...`. Protects against a malicious / MITM response pointing the bitmap decoder at an attacker-controlled HTTPS server. - `XkcdApiClient.fetchRandom` — made suspend + added `coroutineContext.ensureActive()` at the top of each loop iteration. Before, an unbounded loop with no cancellation point would run to completion if the Fragment was torn down mid-fetch on a flaky network. Now it exits with `CancellationException` cooperatively. - `XkcdApiClient.getJson` — bound the JSON read at 64 KB via `Content-Length` check. Stops a pathological response from OOM-ing the parser; xkcd's JSON is < 1 KB in practice so the cap is generous. Conventions parity with other plugin-examples plugins: - `minSdk` dropped from 28 → 26 to match apk-viewer / keystore-generator / markdown-preview / snippets / ndk-installer-plugin. No API-28-only call sites in this code; the stricter target was incidental. - Dead `R.string.tab_title` removed from `strings.xml` — `XkcdRandomPlugin.getEditorTabs()` hardcodes `title = "XKCD"` so the string was never read. Documentation honesty: - `XkcdApiClient` class doc + per-method docs telegraph that all public methods do blocking HTTP I/O and must be called from `Dispatchers.IO`. `fetchRandom` is suspend; the others are plain blocking funs. - `XkcdPanelFragment.lastBytes` field doc spells out the 5 MB heap-pin tradeoff and lists the alternatives (re-fetch, re-compress, disk cache) so a copy-paster doesn't internalize the pattern as default. - `Random.nextInt(1, latest.num + 1)` annotated with `// upper bound exclusive` next to the call. Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/build.gradle.kts | 2 +- .../xkcdrandom/fragments/XkcdPanelFragment.kt | 10 +++ .../xkcdrandom/net/XkcdApiClient.kt | 69 ++++++++++++++----- random-xkcd/src/main/res/values/strings.xml | 2 - 4 files changed, 61 insertions(+), 22 deletions(-) diff --git a/random-xkcd/build.gradle.kts b/random-xkcd/build.gradle.kts index 4951721..2efaf76 100644 --- a/random-xkcd/build.gradle.kts +++ b/random-xkcd/build.gradle.kts @@ -16,7 +16,7 @@ android { defaultConfig { applicationId = "com.codeonthego.xkcdrandom" - minSdk = 28 + minSdk = 26 targetSdk = 34 versionCode = 1 versionName = "1.0.0" diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt index a44c031..c83b776 100644 --- a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt @@ -75,6 +75,16 @@ class XkcdPanelFragment : Fragment() { * Raw PNG bytes of the currently-displayed comic. Kept in memory so a * triple-tap can copy the image to the clipboard without re-downloading * or re-encoding the rendered Bitmap. + * + * **Tradeoff worth knowing if you copy this pattern:** this pins up to + * `MAX_IMAGE_BYTES` (5 MB) on the heap for the Fragment's lifetime. On a + * 2 GB device that's a noticeable allocation. Alternatives: + * - re-fetch on triple-tap (slow, requires network) + * - re-compress the rendered `Bitmap` back to PNG on demand (CPU spike) + * - write to disk on every fetch (the old approach — adds I/O on success + * path, was removed for simplicity) + * The in-memory buffer is the right call for a demo plugin; if you write + * a plugin that holds multiple images, reconsider. */ private var lastBytes: ByteArray? = null diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt index 44f28e0..b680fbc 100644 --- a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt @@ -1,11 +1,13 @@ package com.codeonthego.xkcdrandom.net +import kotlinx.coroutines.ensureActive import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import java.io.IOException import java.io.InputStream import java.util.concurrent.TimeUnit +import kotlin.coroutines.coroutineContext import kotlin.random.Random /** @@ -16,40 +18,53 @@ import kotlin.random.Random * * No auth, no rate-limit headers, no pagination. We keep this client * dependency-light: OkHttp + the org.json reader that ships with Android. + * + * **Threading:** every public method here makes a blocking HTTP call via + * `OkHttpClient.execute()`. Always call these from `Dispatchers.IO` (or a + * thread you don't mind blocking). `fetchRandom` is `suspend` because it + * loops + needs cooperative cancellation; the others are plain blocking + * functions and read top-to-bottom. */ class XkcdApiClient( private val client: OkHttpClient = defaultClient(), ) { /** - * Fetch a random comic. Picks a number in [1, latestNum] and keeps - * picking until one returns a real comic. The only way this returns - * null is if the initial "latest comic" probe fails — i.e. the - * network is down. Once we know the network works, we loop until - * a non-404 number comes back. + * Fetch a random comic. Picks a number in `[1, latestNum]` (upper + * bound exclusive in `Random.nextInt`) and keeps picking until one + * returns a real comic. Returns `null` only if the initial "latest + * comic" probe fails — i.e. the network is down. * * Why the loop is unbounded: xkcd #404 returns HTTP 404 on its JSON * endpoint (the "page not found" joke comic), so a single retry can * still land on the same dud. Looping until success is simpler than - * a retry budget + fallback — and on a healthy network it converges - * in 1-2 picks. + * a retry budget + fallback, and on a healthy network it converges + * in 1-2 picks. The `ensureActive()` at the top of each iteration + * cooperates with `lifecycleScope` cancellation — if the host tears + * the Fragment down mid-fetch, the loop exits with a + * `CancellationException` rather than running to completion. */ - fun fetchRandom(): XkcdComic? { + suspend fun fetchRandom(): XkcdComic? { val latest = fetchLatest() ?: return null while (true) { - val pick = Random.nextInt(1, latest.num + 1) + coroutineContext.ensureActive() + val pick = Random.nextInt(1, latest.num + 1) // upper bound exclusive if (pick == 404) continue fetchByNumber(pick)?.let { return it } - // null = transient blip; just pick again + // null = transient blip; just pick again on the next iteration } } + /** Blocking HTTP GET. Call from `Dispatchers.IO`. */ fun fetchLatest(): XkcdComic? = getJson("https://xkcd.com/info.0.json")?.let(::parseComic) + /** Blocking HTTP GET. Call from `Dispatchers.IO`. */ fun fetchByNumber(num: Int): XkcdComic? = getJson("https://xkcd.com/$num/info.0.json")?.let(::parseComic) /** - * Stream the comic's PNG. Caller must close the returned stream. + * Stream the comic's PNG. **Blocking** — call from `Dispatchers.IO`. + * Caller must close the returned stream. + * * Returns null if the request failed, the response body was empty, * or any IO error occurred (timeout, DNS, TLS). Returning null — * rather than throwing — keeps the caller's empty-state branch @@ -72,8 +87,16 @@ class XkcdApiClient( private fun getJson(url: String): JSONObject? = try { val response = client.newCall(Request.Builder().url(url).build()).execute() response.use { - if (!it.isSuccessful) null - else it.body?.string()?.let(::JSONObject) + if (!it.isSuccessful) return@use null + // Reject pathologically large responses before we slurp them + // into memory. xkcd's comic JSON is < 1 KB in practice; the + // 64 KB cap is generous but bounded. Some servers omit + // Content-Length, in which case we fall through and trust + // OkHttp's read; the upstream byte cap in `fetchAndDecode` + // handles the bitmap path separately. + val contentLength = it.body?.contentLength() ?: -1L + if (contentLength in (MAX_JSON_BYTES + 1)..Long.MAX_VALUE) return@use null + it.body?.string()?.let(::JSONObject) } } catch (_: IOException) { // Network / DNS / TLS failure → behave the same as "comic @@ -83,12 +106,14 @@ class XkcdApiClient( private fun parseComic(obj: JSONObject): XkcdComic? = try { val img = obj.getString("img") - // Defensive parsing: reject anything that isn't an https:// URL. - // The Tier-3 tooltip claims "fetches use HTTPS only"; enforcing the - // claim here protects against a future MITM that swaps in http:// or - // a non-URL scheme. Returning null routes through the same "comic - // unavailable" fallback as a network failure. - if (!img.startsWith("https://")) return null + // Defensive parsing: reject any image URL that isn't hosted on + // xkcd's own image CDN. The Tier-3 tooltip claims "fetches use + // HTTPS only" — enforcing scheme + host here protects against a + // future MITM that swaps in a different host, and against a + // malicious `img` field that points the bitmap decoder at an + // attacker-controlled server. Returning null routes through the + // same "comic unavailable" fallback as a network failure. + if (!img.startsWith(XKCD_IMG_HOST_PREFIX)) return null XkcdComic( num = obj.getInt("num"), title = obj.optString("safe_title", obj.optString("title", "")), @@ -101,6 +126,12 @@ class XkcdApiClient( } companion object { + /** Only accept image URLs served by xkcd's own image CDN. */ + private const val XKCD_IMG_HOST_PREFIX = "https://imgs.xkcd.com/" + + /** Bound the JSON read so a pathological response can't OOM the parser. */ + private const val MAX_JSON_BYTES = 64L * 1024L + private fun defaultClient(): OkHttpClient = OkHttpClient.Builder() .connectTimeout(8, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) diff --git a/random-xkcd/src/main/res/values/strings.xml b/random-xkcd/src/main/res/values/strings.xml index 79ce8d7..fc65618 100644 --- a/random-xkcd/src/main/res/values/strings.xml +++ b/random-xkcd/src/main/res/values/strings.xml @@ -2,8 +2,6 @@ Random XKCD - XKCD - Loading… Could not load a comic — connect to the internet and tap to retry. Tap → new · 2-tap → URL · 3-tap → image From e15b7c6a600c9a1eb9dfeb279a3b3e127895043d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 13 May 2026 01:52:05 +0200 Subject: [PATCH 10/16] chore: update libs from CodeOnTheGo@8e512712d Pulls in TabItem.tooltipTag from the paired CoGo PR (appdevforall/CodeOnTheGo#1297) so random-xkcd can wire its bottom-sheet tab to its own tooltip entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- libs/plugin-api.jar | Bin 170151 -> 170322 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/libs/plugin-api.jar b/libs/plugin-api.jar index 1c07a221033497417bbce658e653859b78d03296..d46c012cfa1f4d8b69bb6cdc325239d346450c58 100644 GIT binary patch delta 12237 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
    mJvh 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 iV1R3~1b`VW0StJm3J9qp~$oV delta 11988 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 tdcVh51ii Date: Wed, 13 May 2026 01:52:05 +0200 Subject: [PATCH 11/16] random-xkcd: wire tab tooltip to plugin's help entry + tutorial pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set `tooltipTag = TOOLTIP_TAG_TAB` on the bottom-sheet TabItem. Long-pressing the XKCD tab now surfaces the plugin's own Tier-1 tooltip ("Random xkcd comic. Tap to roll a new one.") instead of the generic platform placeholder. Requires the paired CoGo PR (appdevforall/CodeOnTheGo#1297). - Tutorial (assets/docs/index.html): - Section 3 (bottom-sheet tab UI): include `tooltipTag` in the TabItem code example + one paragraph explaining the wire to Step 7's DocumentationExtension. - Section 7 (tooltip): back-reference noting that `tag` here is the same string as `TabItem.tooltipTag`. - Tightening pass: -16% words (2482 → 2087). Cuts: end-to-end recap section (duplicate of intro callout), sandbox-model section collapsed to a 3-bullet summary, xkcd license section halved, redundant phrasing across step intros, dropped the standalone "6c. User feedback" subsection. Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/src/main/assets/docs/index.html | 126 +++++------------- .../xkcdrandom/XkcdRandomPlugin.kt | 3 +- 2 files changed, 38 insertions(+), 91 deletions(-) diff --git a/random-xkcd/src/main/assets/docs/index.html b/random-xkcd/src/main/assets/docs/index.html index 20902aa..859fc63 100644 --- a/random-xkcd/src/main/assets/docs/index.html +++ b/random-xkcd/src/main/assets/docs/index.html @@ -30,12 +30,11 @@

    Building the XKCD Plugin

    1. Plugin entry point

    Why you care: every plugin starts with an -IPlugin class that the host loads via -DexClassLoader. The class is your plugin's "main" — the -host reflectively instantiates it by the fully-qualified name in -plugin.main_class, then drives the +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 skeleton right and the rest is just opt-in extensions.

    +Get this right and the rest is opt-in.

    class XkcdRandomPlugin : IPlugin {
     
    @@ -76,12 +75,8 @@ 

    1. Plugin entry point

    2. Manifest + permissions

    -

    Why you care: the manifest is how the host -discovers your plugin, finds the entry class, and validates what -you're allowed to do. Permissions in CoGo gate access to the -host's resources, not your own — which means many things -you'd expect to declare (clipboard, your own -filesDir) actually need no declaration at all.

    +

    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
    @@ -142,13 +137,17 @@ 

    3. The bottom-sheet tab UI

    id = "xkcd_bottom_tab", title = "XKCD", fragmentFactory = { XkcdPanelFragment() }, - order = 200 + order = 200, + tooltipTag = "xkcd.tab" ) ) }
    -

    The Fragment itself is mostly normal Android — except for one -required override:

    +

    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)
    @@ -210,9 +209,6 @@ 

    4. Tap interactions (single / double / triple)

    false // never consume — let ScrollView keep scrolling }
    -

    Returning false is important — consuming the event -would break scroll behavior on tall comics.

    -

    5. Network fetch over HTTPS

    Why you care: most plugins talk to a network @@ -257,13 +253,12 @@

    5. Network fetch over HTTPS

    } }
    -

    Performance note: for very large -images on low-end devices, Android's For production use on low-end devices, prefer +Android's bounded -bitmap decoding pattern (BitmapFactory.Options.inSampleSize -with a two-pass decode) is the production-grade approach. This plugin -keeps it simple with BitmapFactory.decodeByteArray(...) -plus a 5 MB cap on the network read.

    +bitmap decoding pattern. This plugin keeps it simple: +BitmapFactory.decodeByteArray(...) with a 5 MB +network-read cap.

    6. Clipboard support

    @@ -337,12 +332,6 @@

    6b. Image — the FileProvider hop

    image/* to paste targets. Paste into Messages, Gmail, any image-aware app — it pastes the image, not the URL.

    -

    6c. User feedback

    - -

    Every clipboard action shows a toast so the user knows it -happened. The system clipboard is invisible — without feedback, -users can't tell whether a copy succeeded.

    -

    7. Three-tier tooltip for plugin help

    Why you care: CoGo has a per-plugin help API. @@ -388,6 +377,10 @@

    7. Three-tier tooltip for plugin help

    3buttons[].uriA 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 @@ -401,74 +394,27 @@

    7. Three-tier tooltip for plugin help

    in any browser. It renders identically; the localhost:6174 URL is only used when the host serves it inside CoGo.

    -

    What you observe end-to-end

    - -
      -
    1. Plugin appears in Plugin Manager with status "Enabled".
    2. -
    3. "XKCD" tab shows up in the editor bottom sheet.
    4. -
    5. Open the tab → spinner → comic renders with title + alt.
    6. -
    7. Single-tap → new comic loads.
    8. -
    9. Double-tap → toast "URL copied: …" — paste in any text field to confirm.
    10. -
    11. Triple-tap → toast "Comic image copied to clipboard" — paste in Messages to confirm.
    12. -
    13. Long-press the tab → Tier-1 tooltip; "See More" → Tier-2 detail; "Code walkthrough" → opens this page.
    14. -
    -

    The sandbox model in one screen

    -

    Plugins run inside the host IDE's process, but the plugin classes -are loaded by a DexClassLoader that's separate from the -host's class loader. Three durable consequences:

    - -
      -
    1. No plugin manifest registration. The OS never sees - your plugin's AndroidManifest.xml, so - <activity>, <service>, - <provider>, and <receiver> - declarations are dead code. Anything that requires OS - registration must go through the host.
    2. - -
    3. R.* resolves against whichever - Resources your code is using. The host's - LayoutInflater resolves to host resources by default; - wrap with - PluginFragmentHelper.getPluginInflater(pluginId, parent) - to flip it to your plugin's APK.
    4. - -
    5. Permissions gate the host's resources, not your own. - Your filesDir, cacheDir, and the system - clipboard need no declaration. Declare a permission only when - you touch something the host mediates.
    6. -
    - -

    The standard escape valves:

    - +

    Three rules to remember:

      -
    • For UI surfaces — register an extension - (UIExtension, EditorTabExtension, - DocumentationExtension).
    • -
    • For services — fetch via - context.services.get(IdeXxxService::class.java) or - requireContext().getSystemService(...).
    • -
    • For file sharing across apps — copy under - ctx.filesDir and use - FileProvider.getUriForFile(ctx, - "${ctx.packageName}.providers.fileprovider", file).
    • +
    • Manifest <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.
    • +
    • Permissions gate the host's resources only — your + filesDir, cacheDir, and the clipboard + need no declaration.

    xkcd attribution + license

    -

    xkcd comics are © Randall Munroe and licensed CC BY-NC 2.5 -(see xkcd.com/license.html). -This plugin fetches comics over HTTPS, displays them with an -always-visible attribution line ("Comics © Randall Munroe · xkcd.com -· CC BY-NC 2.5" beneath every comic), and is itself non-commercial -(open-source demo plugin for an open-source IDE). No caching, no -redistribution beyond what the user explicitly copies to their own -clipboard.

    - -

    The plugin's own source code is licensed per the surrounding -plugin-examples repository. xkcd's license applies only to -the comic content the plugin displays.

    +

    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.

    Where to go next

    diff --git a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt index 38bb94c..7ed1f62 100644 --- a/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt @@ -76,7 +76,8 @@ class XkcdRandomPlugin : IPlugin, UIExtension, DocumentationExtension { id = TAB_ID, title = "XKCD", fragmentFactory = { XkcdPanelFragment() }, - order = 200 + order = 200, + tooltipTag = TOOLTIP_TAG_TAB ) ) From 7032e85611dd02ef0bad977ddc382ce3fb92a48d Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Wed, 13 May 2026 02:12:29 +0200 Subject: [PATCH 12/16] README: list random-xkcd in the Examples table Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 1 + 1 file changed, 1 insertion(+) 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 From d0ee8bd4f3fbc6c839a3407ab6d3d0bdc0da5479 Mon Sep 17 00:00:00 2001 From: Bryan Chan Date: Thu, 14 May 2026 02:38:14 +0200 Subject: [PATCH 13/16] random-xkcd: rename package to org.appdevforall.randomxkcd + author "App Dev For All" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Daniel-ADFA's PR #6 review comments: - Move Kotlin source from `com.codeonthego.xkcdrandom` to `org.appdevforall.randomxkcd` to match the convention set by `ndk-installer-plugin` (the most recent precedent in the repo). - Update `plugin.id` (`com.codeonthego.xkcdrandom` → `org.appdevforall.randomxkcd`), `plugin.main_class`, `applicationId` / `namespace` in `build.gradle.kts`, the ProGuard `-keep` rule, the Tier-3 walkthrough snippets in `assets/docs/index.html`, the README's source-layout block, and the host-served Tier-3 URL in the README. - Change `plugin.author` from "Code on the Go Team" to "App Dev For All" (consistent with the other plugins, which use "App Dev for All" / "App Dev For All" — adopted Daniel's exact casing). Verified: `./gradlew testDebugUnitTest` green; `./gradlew clean assemblePluginDebug` produces a `.cgp` whose packaged manifest reports the new id, author, and main_class via `aapt2 dump xmltree`. Co-Authored-By: Claude Opus 4.7 (1M context) --- random-xkcd/README.md | 4 ++-- random-xkcd/build.gradle.kts | 4 ++-- random-xkcd/proguard-rules.pro | 2 +- random-xkcd/src/main/AndroidManifest.xml | 6 +++--- random-xkcd/src/main/assets/docs/index.html | 6 +++--- .../appdevforall/randomxkcd}/XkcdRandomPlugin.kt | 12 ++++++------ .../randomxkcd}/fragments/XkcdPanelFragment.kt | 12 ++++++------ .../appdevforall/randomxkcd}/net/XkcdApiClient.kt | 2 +- .../appdevforall/randomxkcd}/net/XkcdComic.kt | 2 +- .../randomxkcd}/ui/TapCountClassifier.kt | 2 +- .../randomxkcd}/ui/TapCountClassifierTest.kt | 4 ++-- 11 files changed, 28 insertions(+), 28 deletions(-) rename random-xkcd/src/main/kotlin/{com/codeonthego/xkcdrandom => org/appdevforall/randomxkcd}/XkcdRandomPlugin.kt (92%) rename random-xkcd/src/main/kotlin/{com/codeonthego/xkcdrandom => org/appdevforall/randomxkcd}/fragments/XkcdPanelFragment.kt (98%) rename random-xkcd/src/main/kotlin/{com/codeonthego/xkcdrandom => org/appdevforall/randomxkcd}/net/XkcdApiClient.kt (99%) rename random-xkcd/src/main/kotlin/{com/codeonthego/xkcdrandom => org/appdevforall/randomxkcd}/net/XkcdComic.kt (93%) rename random-xkcd/src/main/kotlin/{com/codeonthego/xkcdrandom => org/appdevforall/randomxkcd}/ui/TapCountClassifier.kt (98%) rename random-xkcd/src/test/kotlin/{com/codeonthego/xkcdrandom => org/appdevforall/randomxkcd}/ui/TapCountClassifierTest.kt (97%) diff --git a/random-xkcd/README.md b/random-xkcd/README.md index 4695c14..d49aaec 100644 --- a/random-xkcd/README.md +++ b/random-xkcd/README.md @@ -12,7 +12,7 @@ called out where it shows up in the code. 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` +`http://localhost:6174/plugin/org.appdevforall.randomxkcd/index.html` once the plugin is installed. To read it: @@ -53,7 +53,7 @@ random-xkcd/ │ ├── 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/ + ├── kotlin/org/appdevforall/randomxkcd/ │ ├── XkcdRandomPlugin.kt ← lifecycle + tab + tooltip registration │ ├── fragments/XkcdPanelFragment.kt │ ├── net/XkcdApiClient.kt ← HTTP, two endpoints, no auth diff --git a/random-xkcd/build.gradle.kts b/random-xkcd/build.gradle.kts index 2efaf76..1207779 100644 --- a/random-xkcd/build.gradle.kts +++ b/random-xkcd/build.gradle.kts @@ -11,11 +11,11 @@ pluginBuilder { } android { - namespace = "com.codeonthego.xkcdrandom" + namespace = "org.appdevforall.randomxkcd" compileSdk = 34 defaultConfig { - applicationId = "com.codeonthego.xkcdrandom" + applicationId = "org.appdevforall.randomxkcd" minSdk = 26 targetSdk = 34 versionCode = 1 diff --git a/random-xkcd/proguard-rules.pro b/random-xkcd/proguard-rules.pro index 2ccccf8..7e6ae80 100644 --- a/random-xkcd/proguard-rules.pro +++ b/random-xkcd/proguard-rules.pro @@ -1,4 +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.** { *; } +-keep class org.appdevforall.randomxkcd.** { *; } diff --git a/random-xkcd/src/main/AndroidManifest.xml b/random-xkcd/src/main/AndroidManifest.xml index f88a914..65cdb65 100644 --- a/random-xkcd/src/main/AndroidManifest.xml +++ b/random-xkcd/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ + android:value="org.appdevforall.randomxkcd" /> + android:value="App Dev For All" /> + android:value="org.appdevforall.randomxkcd.XkcdRandomPlugin" /> + + + + + + + +