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

+ +
class XkcdRandomPlugin : IPlugin {
+
+    private lateinit var context: PluginContext
+
+    companion object {
+        const val PLUGIN_ID = "com.codeonthego.xkcdrandom"
+    }
+
+    override fun initialize(context: PluginContext): Boolean {
+        return try {
+            this.context = context
+            context.logger.info("XkcdRandomPlugin initialized")
+            true
+        } catch (t: Throwable) {
+            context.logger.error("XkcdRandomPlugin initialization failed", t)
+            false
+        }
+    }
+
+    override fun activate(): Boolean { … }
+    override fun deactivate(): Boolean { … }
+    override fun dispose() { … }
+}
+ +

Plugin concept — wrap initialize +in try/catch. A stray exception here crashes the host IDE on plugin +load. Return false on failure; the host then skips +activate() for this plugin and keeps the rest of the IDE +alive.

+ +

Plugin concept — PluginContext +is your handle to the host. It exposes a per-plugin +logger, a services registry (look up +IdeEditorTabService, IdeTooltipService, etc.), +and resource access. Stash the reference in initialize and +use it from every other method.

+ +

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="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,
+            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. 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
+}
+ +

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

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/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt new file mode 100644 index 0000000..7ed1f62 --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/XkcdRandomPlugin.kt @@ -0,0 +1,133 @@ +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.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 tap surface — tap / double-tap / triple-tap. + * 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 + tap handling + * - [com.codeonthego.xkcdrandom.net.XkcdApiClient]: HTTP, single file + * - [com.codeonthego.xkcdrandom.ui.TapCountClassifier]: the 1/2/3 tap + * state machine, with unit tests + */ +class XkcdRandomPlugin : IPlugin, UIExtension, DocumentationExtension { + + private lateinit var context: PluginContext + + companion object { + const val PLUGIN_ID = "com.codeonthego.xkcdrandom" + 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 = "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" +} 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..c83b776 --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/fragments/XkcdPanelFragment.kt @@ -0,0 +1,353 @@ +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 +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 +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 +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 +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 OnTouchListener: where ACTION_UP feeds the + * [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() { + + private val api = XkcdApiClient() + 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 + 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 + + /** 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. + * + * **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 + + /** 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()) } + + 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) + + // 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 + } + + // 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() { + mainHandler.removeCallbacks(resolveBurstRunnable) + imageCard = null + imageView = null + captionView = null + altView = null + progressView = null + emptyView = null + 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 + when (c) { + TapCountClassifier.Classification.SINGLE -> loadRandomComic() + 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() + } + + // --- rendering --- + + private fun showLoading() { + progressView?.visibility = View.VISIBLE + emptyView?.visibility = View.GONE + } + + 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) + } + + 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 + // 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, bytes, bmp) = result ?: run { + if (currentComic == null) showEmptyState() else { + progressView?.visibility = View.GONE + toast(getString(R.string.toast_fetch_failed)) + } + return@launch + } + currentComic = comic + lastBytes = bytes + showComic(comic, bmp) + } + } + + /** 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 + // 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 Triple(comic, bytes, 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..b680fbc --- /dev/null +++ b/random-xkcd/src/main/kotlin/com/codeonthego/xkcdrandom/net/XkcdApiClient.kt @@ -0,0 +1,140 @@ +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 + +/** + * 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. + * + * **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]` (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. 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. + */ + suspend fun fetchRandom(): XkcdComic? { + val latest = fetchLatest() ?: return null + while (true) { + 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 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. **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 + * 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) 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 + // 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 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", "")), + alt = obj.optString("alt", ""), + imageUrl = img, + ) + } catch (_: Exception) { + // Malformed payload → null, treated as a fetch failure. + null + } + + 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) + .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/" +} 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/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..fc65618 --- /dev/null +++ b/random-xkcd/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + + Random 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 @@ + + + + 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()) + } +}