diff --git a/README.md b/README.md index 78f986f..5695772 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ See the official [plugin documentation](https://www.appdevforall.org/codeonthego | [`markdown-preview/`](markdown-preview/) | Renders Markdown files with a live preview pane in the editor. | | [`keystore-generator/`](keystore-generator/) | Generates signing keystores from inside the IDE. | | [`snippets/`](snippets/) | Adds user-managed code snippets with prefix-triggered expansions. | +| [`random-xkcd/`](random-xkcd/) | Random xkcd comic in the editor bottom sheet; canonical small-plugin walkthrough with in-IDE help. | ## Building a plugin diff --git a/random-xkcd/.gitignore b/random-xkcd/.gitignore new file mode 100644 index 0000000..6af499b --- /dev/null +++ b/random-xkcd/.gitignore @@ -0,0 +1,29 @@ +# Gradle +.gradle/ +build/ +gradle-app.setting +!gradle-wrapper.jar +.gradletasknamecache + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.project +.classpath +.settings/ +.kotlin/ + +# Local configuration +local.properties + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +# Test outputs +test-results/ diff --git a/random-xkcd/README.md b/random-xkcd/README.md new file mode 100644 index 0000000..7052b8d --- /dev/null +++ b/random-xkcd/README.md @@ -0,0 +1,92 @@ +# random-xkcd + +A small Code on the Go plugin that shows a random xkcd comic in the +editor bottom sheet. Three buttons above the comic — Random / Copy +URL / Copy image — mirror the xkcd.com control bar. Tap the image +to copy URL, double-tap to copy 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/org.appdevforall.randomxkcd/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. UI interactions: buttons + gestures (`GestureDetector.SimpleOnGestureListener`) +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/org/appdevforall/randomxkcd/ + │ ├── XkcdRandomPlugin.kt ← lifecycle + tab + tooltip registration + │ ├── fragments/XkcdPanelFragment.kt ← button wiring + GestureDetector + │ ├── net/XkcdApiClient.kt ← HTTP, two endpoints, no auth + │ └── net/XkcdComic.kt + └── res/ + ├── layout/fragment_xkcd_panel.xml + └── values/, values-night/ +``` + +No custom test surface — every piece is small enough that JVM unit +tests would just re-test Android framework behavior. UX is covered +by mobile-MCP / Android QA on real devices. + +## Run tests + +```bash +./gradlew testDebugUnitTest +``` + +## xkcd attribution + license + +xkcd comics are © Randall Munroe and licensed **CC BY-NC 2.5** +(https://xkcd.com/license.html). This plugin: + +- Fetches comics over HTTPS from xkcd.com (no caching, no redistribution + beyond what the user explicitly copies to their own clipboard). +- Displays an attribution line — *"Comics © Randall Munroe · xkcd.com · + CC BY-NC 2.5"* — beneath every comic in the bottom-sheet panel. +- Is itself non-commercial (open-source demo plugin for an + open-source IDE), consistent with the NC term. + +The plugin's own source code is licensed per the surrounding +`plugin-examples` repository (see `LICENSE` at the repo root). xkcd's +license applies only to the comic content the plugin displays. diff --git a/random-xkcd/build.gradle.kts b/random-xkcd/build.gradle.kts new file mode 100644 index 0000000..1207779 --- /dev/null +++ b/random-xkcd/build.gradle.kts @@ -0,0 +1,93 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("com.itsaky.androidide.plugins.build") +} + +pluginBuilder { + pluginName = "random-xkcd" +} + +android { + namespace = "org.appdevforall.randomxkcd" + compileSdk = 34 + + defaultConfig { + applicationId = "org.appdevforall.randomxkcd" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + packaging { + resources { + excludes += setOf( + "META-INF/versions/9/OSGI-INF/MANIFEST.MF", + "META-INF/DEPENDENCIES", + "META-INF/LICENSE", + "META-INF/LICENSE.txt", + "META-INF/NOTICE", + "META-INF/NOTICE.txt" + ) + } + } + + testOptions { + unitTests.isReturnDefaultValues = true + } +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +dependencies { + // The plugin-api jar is the canonical contract for plugins. Available + // at compile time; the IDE provides it at runtime. + compileOnly(files("../libs/plugin-api.jar")) + + implementation("androidx.appcompat:appcompat:1.6.1") + implementation("com.google.android.material:material:1.10.0") + implementation("androidx.fragment:fragment-ktx:1.8.8") + implementation("androidx.core:core-ktx:1.13.1") + implementation("org.jetbrains.kotlin:kotlin-stdlib:2.3.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1") + + // OkHttp for the xkcd JSON endpoint + image fetch. + // Kept tiny and dependency-free — no Glide/Retrofit, since this plugin is a + // teaching example and we want the network layer to read top-to-bottom. + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + testImplementation("junit:junit:4.13.2") +} + +tasks.wrapper { + gradleVersion = "8.14.3" + distributionType = Wrapper.DistributionType.BIN +} + +// Disable AAR metadata checks that fail under the plugin-builder +// pipeline (Beepy + Forms use the same workaround). +tasks.matching { + it.name.contains("checkDebugAarMetadata") || + it.name.contains("checkReleaseAarMetadata") +}.configureEach { + enabled = false +} diff --git a/random-xkcd/gradle.properties b/random-xkcd/gradle.properties new file mode 100644 index 0000000..2c9f545 --- /dev/null +++ b/random-xkcd/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +android.nonTransitiveRClass=true +kotlin.code.style=official diff --git a/random-xkcd/gradle/wrapper/gradle-wrapper.jar b/random-xkcd/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000..8bdaf60 Binary files /dev/null and b/random-xkcd/gradle/wrapper/gradle-wrapper.jar differ diff --git a/random-xkcd/gradle/wrapper/gradle-wrapper.properties b/random-xkcd/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/random-xkcd/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/random-xkcd/gradlew b/random-xkcd/gradlew new file mode 100755 index 0000000..ef07e01 --- /dev/null +++ b/random-xkcd/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/random-xkcd/gradlew.bat b/random-xkcd/gradlew.bat new file mode 100755 index 0000000..db3a6ac --- /dev/null +++ b/random-xkcd/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/random-xkcd/proguard-rules.pro b/random-xkcd/proguard-rules.pro new file mode 100644 index 0000000..7e6ae80 --- /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 org.appdevforall.randomxkcd.** { *; } 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..674f4f6 --- /dev/null +++ b/random-xkcd/src/main/AndroidManifest.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..b5cc776 --- /dev/null +++ b/random-xkcd/src/main/assets/docs/index.html @@ -0,0 +1,460 @@ + + + + + + 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. A 5-button row above the comic + (|< · Prev · Random · Next · >|) mirrors + xkcd.com's own navigation bar; the panel defaults to the latest + comic. Single-tap on the comic copies the URL; double-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. The host loads it via +DexClassLoader, reflectively instantiates it from +plugin.main_class, and drives the +initialize → activate → deactivate → dispose lifecycle. +Get this right and the rest is opt-in.

+ +
class XkcdRandomPlugin : IPlugin {
+
+    private lateinit var context: PluginContext
+
+    companion object {
+        const val PLUGIN_ID = "org.appdevforall.randomxkcd"
+    }
+
+    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 tells the host how +to find your plugin and what host resources you're allowed to touch.

+ +
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+    <application
+        android:label="@string/app_name"
+        android:theme="@style/PluginTheme">
+
+        <meta-data android:name="plugin.id"
+                   android:value="org.appdevforall.randomxkcd" />
+        <meta-data android:name="plugin.name"
+                   android:value="Random XKCD" />
+        <meta-data android:name="plugin.main_class"
+                   android:value="org.appdevforall.randomxkcd.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 copy-image +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,
+            tooltipTag = "xkcd.tab"
+        )
+    )
+}
+ +

tooltipTag connects this tab to the help entry from +DocumentationExtension (Step 7). Omit it and the host +falls back to "<pluginId>.<tabId>".

+ +

The Fragment needs one non-obvious override:

+ +
override fun onGetLayoutInflater(savedInstanceState: Bundle?): LayoutInflater {
+    val inflater = super.onGetLayoutInflater(savedInstanceState)
+    return PluginFragmentHelper.getPluginInflater(XkcdRandomPlugin.PLUGIN_ID, inflater)
+}
+ +

Plugin concept — plugin Fragments must wrap +their LayoutInflater. Plugins load via +DexClassLoader. The inflater the host hands you defaults +to resolving R.layout.* against the host IDE's +resources, not yours. Skip the wrap and you crash with +Resources$NotFoundException the first time the Fragment +shows. PluginFragmentHelper.getPluginInflater(pluginId, parent) +wraps the inflater with a Resources instance backed by +your plugin's APK.

+ +

Plugin concept — +fragmentFactory returns a fresh instance every time. +Never cache a singleton Fragment; the host calls the factory whenever +the tab is shown, and Fragment lifecycle expectations require a clean +instance each time.

+ +

4. UI interactions: navigation buttons + gestures

+ +

Why you care: the canonical surface for plugin +actions is buttons in the layout — they're discoverable, accessible, +and easy to test. Gestures on top of that are optional convenience. +Android's GestureDetector.SimpleOnGestureListener handles +single + double tap with two callbacks. Don't reach for a custom tap +state machine if your model fits this pattern.

+ +

The XKCD plugin's button row mirrors the +xkcd.com navigation bar — five +buttons, left-to-right: |< · Prev · Random · Next · >|. +Each is a normal Button wired with +setOnClickListener; all five route through one +loadComic(nav) helper that fetches + renders:

+ +
btnFirst?.setOnClickListener  { loadComic(Navigation.First) }
+btnPrev?.setOnClickListener   { loadComic(Navigation.Prev) }
+btnRandom.setOnClickListener  { loadComic(Navigation.Random) }
+btnNext?.setOnClickListener   { loadComic(Navigation.Next) }
+btnLast?.setOnClickListener   { loadComic(Navigation.Last) }
+ +

Plugin-friendly UX detail — +default to Last. On first show the fragment calls +loadComic(Navigation.Last) so the user lands on the +newest comic, exactly like opening xkcd.com in a browser. Prev / Next +are disabled until we know the current cursor + the high-water mark, +so the buttons never lie about what they can do.

+ +

Edge case — comic #404. xkcd #404 +is a deliberate joke: its JSON endpoint returns HTTP 404. The +fetch handler steps past it in the same direction the user requested +(Prev to #403, Next to #405), so the user never sees a "failed to +load" toast for a quirk of the source data.

+ +

Gesture shortcuts on the image itself are pure +GestureDetector:

+ +
private inner class ImageGestureListener : GestureDetector.SimpleOnGestureListener() {
+    override fun onDown(e: MotionEvent) = true  // required to receive subsequent events
+
+    override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
+        copyUrlToClipboard()
+        return true
+    }
+
+    override fun onDoubleTap(e: MotionEvent): Boolean {
+        copyImageToClipboard()
+        return true
+    }
+}
+
+val gestureDetector = GestureDetector(view.context, ImageGestureListener())
+imageView.setOnTouchListener { v, event ->
+    val handled = gestureDetector.onTouchEvent(event)
+    if (event.actionMasked == MotionEvent.ACTION_UP) v.performClick()
+    handled
+}
+ +

The detector is attached to the ImageView only — +the rest of the panel scrolls normally. onSingleTapConfirmed +fires after Android's double-tap window expires, so it doesn't +preempt double-tap.

+ +

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

For production use on low-end devices, prefer +Android's bounded +bitmap decoding pattern. This plugin keeps it simple: +BitmapFactory.decodeByteArray(...) with a 5 MB +network-read cap.

+ +

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.

+ +

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 = "Browse xkcd. Buttons navigate · tap = copy URL · double-tap = copy image.",
+            detail = """
+                <p>Defaults to the latest comic. Navigate with
+                <b>|< · Prev · Random · Next · >|</b>.</p>
+                <ul>
+                  <li><b>Tap image</b> — copy URL.</li>
+                  <li><b>Double-tap image</b> — copy 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>.
+ +

tag here is the same string you put on +TabItem.tooltipTag (Step 3) — that's the wire that +connects the long-press on the tab to this entry.

+ +

getTier3DocsAssetPath() = "docs" says: "my Tier 3 +walkthrough lives under src/main/assets/docs/." At +install time the host's Tier3AssetWalker indexes +everything under that directory and serves each file at the URL +above. Files reference each other with relative paths — so this +page's <link rel="stylesheet" href="css/walkthrough.css"> +just works.

+ +

To preview the Tier 3 page outside the +IDE: just open random-xkcd/src/main/assets/docs/index.html +in any browser. It renders identically; the localhost:6174 URL is +only used when the host serves it inside CoGo.

+ +

The sandbox model in one screen

+ +

Three rules to remember:

+ + +

xkcd attribution + license

+ +

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/assets/icon_day.png b/random-xkcd/src/main/assets/icon_day.png new file mode 100644 index 0000000..0e432bb Binary files /dev/null and b/random-xkcd/src/main/assets/icon_day.png differ diff --git a/random-xkcd/src/main/assets/icon_night.png b/random-xkcd/src/main/assets/icon_night.png new file mode 100644 index 0000000..1e38d87 Binary files /dev/null and b/random-xkcd/src/main/assets/icon_night.png differ diff --git a/random-xkcd/src/main/kotlin/org/appdevforall/randomxkcd/XkcdRandomPlugin.kt b/random-xkcd/src/main/kotlin/org/appdevforall/randomxkcd/XkcdRandomPlugin.kt new file mode 100644 index 0000000..9ad32fb --- /dev/null +++ b/random-xkcd/src/main/kotlin/org/appdevforall/randomxkcd/XkcdRandomPlugin.kt @@ -0,0 +1,140 @@ +package org.appdevforall.randomxkcd + +import org.appdevforall.randomxkcd.fragments.XkcdPanelFragment +import com.itsaky.androidide.plugins.IPlugin +import com.itsaky.androidide.plugins.PluginContext +import com.itsaky.androidide.plugins.extensions.DocumentationExtension +import com.itsaky.androidide.plugins.extensions.PluginTooltipButton +import com.itsaky.androidide.plugins.extensions.PluginTooltipEntry +import com.itsaky.androidide.plugins.extensions.TabItem +import com.itsaky.androidide.plugins.extensions.UIExtension + +/** + * Random-xkcd demo plugin. Three goals: + * 1. Show a random xkcd comic in the bottom-sheet "XKCD" tab. + * 2. Demonstrate the canonical UI surface — buttons above the comic + * plus Android's standard single/double-tap gestures on the image. + * 3. Be a small, readable "how to write a CoGo plugin" example. + * + * Reading order: + * - this file: lifecycle + tab registration + tooltip / docs wiring + * - [XkcdPanelFragment]: the bottom-sheet UI + button wiring + gestures + * - [org.appdevforall.randomxkcd.net.XkcdApiClient]: HTTP, single file + */ +class XkcdRandomPlugin : IPlugin, UIExtension, DocumentationExtension { + + private lateinit var context: PluginContext + + companion object { + const val PLUGIN_ID = "org.appdevforall.randomxkcd" + const val TAB_ID = "xkcd_bottom_tab" + const val TOOLTIP_TAG_TAB = "xkcd.tab" + } + + 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") + } + + // --- 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, + tooltipTag = TOOLTIP_TAG_TAB + ) + ) + + // --- DocumentationExtension: three-tier tooltip on the tab --- + // + // CoGo's per-plugin help API. Long-pressing the bottom-sheet tab + // shows Tier 1; the tooltip's "See More" button reveals Tier 2; + // a button inside Tier 2 opens Tier 3 as a full HTML page. + // + // Tier 1 = `summary` (one-liner) + // Tier 2 = `detail` (HTML paragraph) + // Tier 3 = `buttons[].uri` (HTML page the IDE serves at + // http://localhost:6174/plugin//) + // + // 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 = "Browse xkcd. Buttons navigate · tap = copy URL · double-tap = copy image.", + detail = """ +

This panel browses xkcd.com. Defaults to the + latest comic.

+

Navigation row above the comic (left to right):

+
    +
  • |< — first comic.
  • +
  • Prev — previous comic.
  • +
  • Random — a random comic.
  • +
  • Next — next comic.
  • +
  • >| — latest comic.
  • +
+

Gesture shortcuts on the image:

+
    +
  • Tap — copy the comic's URL.
  • +
  • Double-tap — copy the comic image.
  • +
+

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/org.appdevforall.randomxkcd/ + * + * Files reference each other with relative paths (e.g. css/walkthrough.css). + */ + override fun getTier3DocsAssetPath(): String? = "docs" +} diff --git a/random-xkcd/src/main/kotlin/org/appdevforall/randomxkcd/fragments/XkcdPanelFragment.kt b/random-xkcd/src/main/kotlin/org/appdevforall/randomxkcd/fragments/XkcdPanelFragment.kt new file mode 100644 index 0000000..ea34ac3 --- /dev/null +++ b/random-xkcd/src/main/kotlin/org/appdevforall/randomxkcd/fragments/XkcdPanelFragment.kt @@ -0,0 +1,444 @@ +package org.appdevforall.randomxkcd.fragments + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.graphics.BitmapFactory +import android.os.Build +import android.os.Bundle +import android.view.GestureDetector +import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.FrameLayout +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 org.appdevforall.randomxkcd.R +import org.appdevforall.randomxkcd.XkcdRandomPlugin +import org.appdevforall.randomxkcd.net.XkcdApiClient +import org.appdevforall.randomxkcd.net.XkcdComic +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 +import java.io.File + +/** + * The "XKCD" tab body. + * + * 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 button-row wiring + image gesture detector: xkcd.com-style + * 5-button navigation (|< · < Prev · Random · Next > · >|) above + * the comic, plus single/double-tap shortcuts on the image itself. + * - the navigation handlers (goFirst / goPrev / goRandom / goNext / + * goLast) all go through [loadComic], which dispatches the right + * fetch and re-renders. + * + * Navigation state: + * - [currentComicNum] — the number we're displaying right now. + * - [latestComicNum] — the high-water mark from xkcd's latest-comic + * probe. Used to disable Next at the upper edge and to validate + * range; refreshed on every Last-button tap. + * + * Edge handling: + * - Comic #404 is xkcd's joke comic: its JSON endpoint returns HTTP + * 404 deliberately. When a Prev / Next step would land on #404, + * the fetch handler keeps stepping in the same direction (Prev + * → 403; Next → 405) so the user never sees a "failed to load" + * toast for a quirk of the source data. + */ +class XkcdPanelFragment : Fragment() { + + private val api = XkcdApiClient() + + // 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 + private var btnFirst: Button? = null + private var btnPrev: Button? = null + private var btnNext: Button? = null + private var btnLast: Button? = null + + /** The comic we're currently displaying — used by the clipboard handlers. */ + private var currentComic: XkcdComic? = null + + /** Number of the comic currently displayed. Drives Prev/Next disable logic. */ + private var currentComicNum: Int? = null + + /** + * High-water mark — the latest comic number known to exist on + * xkcd.com. Set on the first successful Latest fetch (or after Last). + * Used to disable Next at the upper edge and to bound Random. + */ + private var latestComicNum: Int? = null + + /** + * Raw PNG bytes of the currently-displayed comic. Kept in memory so a + * "copy image" tap can hit the clipboard without re-downloading or + * re-encoding the rendered Bitmap. + */ + private var lastBytes: ByteArray? = null + + /** In-flight fetch, so rapid button presses don't fan out into N parallel fetches. */ + private var loadJob: Job? = 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) + btnFirst = view.findViewById(R.id.xkcd_btn_first) + btnPrev = view.findViewById(R.id.xkcd_btn_prev) + btnNext = view.findViewById(R.id.xkcd_btn_next) + btnLast = view.findViewById(R.id.xkcd_btn_last) + val btnRandom = view.findViewById