⏳ This skill is pending AI review.
Scores will appear once the review pipeline completes.
course-to-apk
Package an existing interactive course generated by the book-to-course skill (a folder with index.html, assets/ and course/data.js, or its .zip) into a signed Android APK — a light offline WebView app to install on a phone or tablet. Checks the build tools on the machine (JDK, Android SDK build-tools and platform), tells the user exactly what is missing and where to get it, and can download it for them. Use this whenever the user wants a course "na telefon/tablet", "na Androida", "jako aplikację", "zrób z kursu APK", "course to apk", wants to learn from their generated course on a mobile device offline, or wants to rebuild/update a course app after adding chapters — even if they don't say "APK" explicitly. Do NOT use for building a course from a book (that is book-to-course), for iOS apps, or for general Android app development.
Choose how to use this skill
You do not need every option. Choose the path your AI client supports. The stable page stays the same; versioned files are immutable.
1. Native installer
This listing has no registered native installer command. Use the complete package or source fallback below, depending on what your client supports.
Do not guess an installer command or replace an existing version without reviewing the diff.
2. Complete package recommended
Download the ZIP when available. It includes SKILL.md plus the references, security notes and version metadata.
No complete ProSkills package is published for this listing yet.3. Prompt-only
Copy the prompt above when the agent can read the stable page or when you want to adopt the workflow without installing a skill.
Need only the instruction file?
Download SKILL.md only if your client requires a single file. The complete ZIP is safer for a full installation because it preserves the references and release context.
No path installs or executes anything by itself. Your agent still needs access to the project files. Before updating, compare the installed version and review the diff.
// RATINGS
// README
book-to-course
Turn a book into an interactive course you can run locally in a browser. This Claude skill uses the source material to build beginner-friendly lessons, then adds ways to practise and check what you have learned. It accepts EPUB, PDF, HTML, DOCX, Markdown, and plain text.
What a course can include
- Short lessons with step-by-step explanations, worked examples, diagrams, and animated walkthroughs.
- Interactive quizzes with hints and explanations, chapter tests, and flashcards.
- Programming exercises with starter code, examples, and unit tests. Learners write code in their editor, run the tests from the course page, and see whether their solution passes.
- Progress tracking for lessons, quiz answers, and exercises, saved locally so learners can pick up where they left off.
For coding exercises, the skill checks that the tests fail on the starter code and pass on a reference solution before packaging the course. When a book does not call for code, the course can use other practice activities, such as self-check exercises.
Demo
A Go programming course: an animated code walkthrough, a quiz, flashcards, and an exercise whose unit tests run from the page.

A math course: formulas written in LaTeX render in lesson text, tables, quizzes, and flashcards.

Installation
This repository is a Claude Code plugin marketplace. In Claude Code, add the marketplace and install the plugin:
/plugin marketplace add sebastianhaba/book-to-course-skill
/plugin install book-to-course@book-to-course
Then attach a book and ask Claude to turn it into a course, for example: "Turn this book into an interactive course."
To update to the latest version later, run /plugin marketplace update book-to-course.
Example course
These screenshots show a course generated from a Go programming book:
Course overview and progress

Interactive quiz

Coding exercise and test results

How it works
- Give the skill a book or document and ask it to create a course. You can specify the course language and scope.
- The skill extracts the source, plans lessons by chapter, and builds the course as a folder of local web files.
- Open the generated course with
start.sh(macOS/Linux) orstart.bat(Windows). The local server saves progress toprogress/progress.jsonand lets the Run tests button execute the exercise's test command on your computer.
Starting the course this way requires Python 3. Programming exercises also require the relevant language and test tools, such as Go for a Go course. You can open index.html directly without the server; quizzes and lessons still work, while progress stays in the browser and code tests must be run in a terminal.
The skill and its supporting scripts, templates, and references are in book-to-course/. Generated courses are self-contained folders that can be packaged and shared.
Course on a phone or tablet (Android)
The second skill in this plugin, course-to-apk, packages a generated course as a small offline Android app. Ask, for example: "Make an APK of this course for my tablet."
- The app is a lightweight WebView wrapper: the course is bundled inside the APK, works without internet access, and keeps progress on the device across updates. It runs on Android 7.0 and newer.
- The APK is built straight from the Android SDK tools with no Gradle or Android Studio project, and signed with the Android debug key (
~/.android/debug.keystore). You can also sign it with your own keystore. - Building requires JDK 17+ and Android SDK build-tools plus a platform package. The skill checks for them first. If something is missing, it tells you what to download and where to get it, then either waits for you to install it or downloads it into
~/.local/share/course-to-apkwith your consent (no sudo). - The output goes next to the course, in
<course>-android/. Copy the APK to the device and open it, or install it over USB withadb.
Code exercises can't run their unit tests on a phone, so use the computer version for those. Lessons, quizzes, tests, and flashcards all work in the app.
Course as a Linux desktop app (AppImage)
The third skill, course-to-appimage, packages a generated course as a single, self-contained AppImage. Copy the file to any desktop Linux computer and start it; nothing else needs to be installed. Ask, for example: "Make an AppImage of this course so I can open it from the app menu."
- The app wraps the course in its own WebView engine, the official Electron build of Chromium and Node.js. Nothing is compiled. It also carries a colour emoji font, so emoji display even on systems without one. The AppImage is about 115 MB plus the size of the course, and it runs on current desktop distributions.
- Progress is saved to
~/.local/share/<app-id>/and kept across updates. - Programming exercises work as in the folder version, unlike on a phone, because the AppImage runs as a normal program on your computer. The app copies the exercises to
~/.local/share/<app-id>/exercises/, and each exercise shows its full folder path with an Open folder button. Edit the files in your own editor, then use the Run tests button to run their unit tests with the compiler installed on the computer. The exercise's language tools, such as Go, must be installed. - Building requires
appimagetool, Electron (a one-time download of about 115 MB), and the Noto Color Emoji font, which is usually already installed. The AppImage runtime file is also needed if you want to build offline. The skill checks for them first. If something is missing, it tells you what to download and where to get it, then either waits for you to install it or downloads it into~/.local/share/course-to-appimagewith your consent (no sudo, checksums verified). - The output goes next to the course, in
<course>-appimage/. Start the file directly, or have the skill add it to your application menu, which copies it to~/Applications.
License
Released under the MIT License.
// HOW IT'S BUILT
// FILES
15 files shipped with this skill. Inline text is the catalog copy. The upstream link is the source repository.
course-to-apk/SKILL.md7.4 KB0ed1530ef3b2upstream ↗
--- name: course-to-apk description: Package an existing interactive course generated by the book-to-course skill (a folder with index.html, assets/ and course/data.js, or its .zip) into a signed Android APK — a light offline WebView app to install on a phone or tablet. Checks the build tools on the machine (JDK, Android SDK build-tools and platform), tells the user exactly what is missing and where to get it, and can download it for them. Use this whenever the user wants a course "na telefon/tablet", "na Androida", "jako aplikację", "zrób z kursu APK", "course to apk", wants to learn from their generated course on a mobile device offline, or wants to rebuild/update a course app after adding chapters — even if they don't say "APK" explicitly. Do NOT use for building a course from a book (that is book-to-course), for iOS apps, or for general Android app development. --- # Course → Android APK Wraps a finished book-to-course course into a small Android app: one Activity with a WebView that serves the course files from inside the APK. No Gradle, no Android Studio project, no network permission — the build calls the SDK tools directly (`aapt2 → javac → d8 → zipalign → apksigner`), so the APK is roughly the size of the course plus ~10 KB. The app wrapper (`assets/android/`) and the scripts are fixed and tested — **your job is the conversation around them**: find the course, get the toolchain in place with the user's consent, build, and explain how to put it on the device. | script (in `scripts/`) | purpose | |---|---| | `toolchain.py [--json]` | what is installed, what is missing, where to download it; exit 0 = ready | | `setup_toolchain.py --dry-run` | list exactly what would be downloaded (name, size, URL) — downloads nothing | | `setup_toolchain.py --show-license` | print the Android SDK licence text | | `setup_toolchain.py --accept-licenses` | download only the missing parts into `~/.local/share/course-to-apk` (no sudo) | | `build_apk.py COURSE_DIR [--install]` | build the signed APK into `<COURSE_DIR>-android/` | All scripts are Python 3 standard library only. ## Workflow ### 1. Find the course A course folder has `index.html`, `assets/app.js` and `course/data.js`. Look where the user points, in the current directory, or among recent book-to-course output. If they give a `.zip` (from `pack_course.py`), unzip it next to the zip first — it contains one top folder named after the course; that folder is COURSE_DIR. If `content/` exists but `course/data.js` is missing or older than the lessons, the course was not rebuilt — run book-to-course's `build_course.py COURSE_DIR` first (when that skill is available), otherwise ask the user to. ### 2. Check the toolchain ```bash python3 scripts/toolchain.py ``` It searches well beyond PATH (JAVA_HOME, ANDROID_HOME, `~/Android/Sdk`, `~/.jdks`, Android Studio's bundled JBR, `/usr/lib/jvm`, …), so a JDK that `which java` does not see is still found. If it prints **READY**, go to step 4. ### 3. Something is missing → ask, don't assume Tell the user, in their language, what is missing — use the report's lines, they already contain the official download links and package-manager commands for their OS. Then run `setup_toolchain.py --dry-run` so you can quote the real sizes and URLs, and ask them to choose (AskUserQuestion works well here): - **"I'll install it myself"** — give the list again as a short checklist (what, where from, and that a non-standard location needs `JAVA_HOME` / `ANDROID_HOME`). Then **stop and wait**. Do not poll, do not build, do not download anything. When they write that it's installed, rerun `toolchain.py`; if something is still missing, say exactly what and wait again. - **"Download it for me"** — the Android SDK packages are under the Android SDK License Agreement (https://developer.android.com/studio/terms; full text via `--show-license`). Their choice of this option must include agreeing to it — say so in the question. Only then run `python3 scripts/setup_toolchain.py --accept-licenses` (≈320 MB if the JDK is also missing, ≈125 MB otherwise; it verifies checksums and installs only into `~/.local/share/course-to-apk`, nothing system-wide, no sudo). Why the care: downloading hundreds of megabytes and accepting a licence are the user's decisions, not yours, and a user who said they'd install things themselves is mid-installation — building or downloading behind their back collides with that. ### 4. Build ```bash python3 scripts/build_apk.py "<COURSE_DIR>" ``` Defaults that are right for almost everyone — mention them, don't ask: - **Output** `<COURSE_DIR>-android/<course-id>.apk`, next to the course folder; `apk.json` there remembers the package id, label, icon colour and an auto-incremented versionCode. - **Signing** with the Android debug key `~/.android/debug.keystore` (the standard developer certificate; created with keytool if it doesn't exist). Fine for installing on your own devices; it is not a Play Store release key. If the user has their own keystore: `--keystore FILE --key-alias A --ks-pass P` (or `APK_KS_PASS` env var so the password doesn't land in shell history); it's remembered in `apk.json`. - **Package id** `net.booktocourse.<course_id>`, **label** = course title. Override with `--package` / `--label` only when asked — changing the package later makes Android treat it as a different app and the learner loses progress. - **Android 7.0+** (minSdk 24); targetSdk = the newest platform installed. If the build fails, the error names the tool and shows its output; see `references/troubleshooting.md`. ### 5. Hand it over Tell the user in a few lines: where the APK is (absolute path), its size, and how to install it: - **Copy to the device** (USB cable, cloud drive, e-mail to yourself), open the file on the device, allow "install unknown apps" for the app that opens it when Android asks; Play Protect may warn about an unknown developer → "Install anyway". - **Or over USB with adb**: enable Developer options → USB debugging, connect, then `build_apk.py … --install` (or `adb install -r <apk>`). Offer this if `toolchain.py` showed adb and a device is connected (`adb devices`). Also say, briefly: - Progress is stored inside the app on the device (not in `progress/progress.json` of the computer copy) and survives updates — **rebuild with the same folder and key** after adding chapters and install over the old app. Uninstalling deletes it. - Code exercises: the "Run tests" button needs the computer version (`start.sh`/`start.bat`); on the phone the page shows the test command instead. Lessons, quizzes, tests and flashcards all work offline. ## What the app does (so you can answer questions) - Serves the course from `https://appassets.androidplatform.net/` via `shouldInterceptRequest` — a reserved host that never hits the network, giving the page a stable origin for `localStorage`. No INTERNET permission; links to other sites open in the user's browser. - Back button/gesture goes back through the course's history, then closes the app; edge-to-edge insets (status bar, cutouts, keyboard) are handled; dark/light follows the system on first launch, then the course's own theme toggle. - `alert()`/`confirm()` show real dialogs (the player avoids them anyway). To change the wrapper itself (e.g. lock orientation), edit `assets/android/` — `MainActivity.java`, `AndroidManifest.xml` (placeholders `{{PACKAGE}}`, `{{LABEL}}`), `res/`. Keep it framework-only: there is no Gradle, so AndroidX libraries can't be added.course-to-apk/scripts/build_apk.py14 KBe6f714f8ed32upstream ↗
#!/usr/bin/env python3 """Build a signed Android APK (a light WebView wrapper) from a course made by book-to-course. Usage: build_apk.py COURSE_DIR [--out DIR] [--package ID] [--label NAME] [--version-name X] [--keystore FILE --ks-pass P --key-alias A [--key-pass P]] [--install] COURSE_DIR the course folder (has index.html, assets/, course/data.js) --out where the Android build lives (default: <COURSE_DIR>-android, next to the course) --package application id, e.g. pl.example.gocourse (default: derived from the course id; remembered in <out>/apk.json) --label name under the launcher icon (default: course title) --keystore ... sign with your own key instead of the Android debug key (~/.android/debug.keystore, created if missing) --install also install on the single device connected over adb (adb install -r) No Gradle: aapt2 → javac → d8 → zipalign → apksigner, straight from the SDK (see toolchain.py for what is needed). Every build bumps versionCode in <out>/apk.json, so a new APK installs over the old one and keeps the learner's progress — as long as the package id and the signing key stay the same. """ import argparse import glob import hashlib import json import os import re import shutil import subprocess import sys import unicodedata import zipfile from xml.sax.saxutils import escape sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import toolchain # noqa: E402 HERE = os.path.dirname(os.path.abspath(__file__)) TEMPLATE = os.path.join(os.path.dirname(HERE), "assets", "android") MIN_SDK = 24 # Android 7.0 # player files the app needs; everything the course page uses is compiled into course/data.js SKIP_TOP = {"content", "exercises", "progress", ".build", ".git", "serve.py", "start.sh", "start.bat", "README.md", "__pycache__", "node_modules", "android"} ICON_COLORS = ["#1E6FD9", "#0F8B8D", "#2E7D32", "#6A4FB6", "#C2185B", "#D84315", "#5D4037", "#37474F", "#00838F", "#AD1457"] JAVA_KEYWORDS = set("""abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null var record yield""".split()) DEBUG_KS = os.path.join(os.path.expanduser("~"), ".android", "debug.keystore") def die(msg): sys.exit("ERROR: " + msg) def run(cmd, env, what): r = subprocess.run(cmd, env=env, capture_output=True, text=True) if r.returncode != 0: die("%s failed (exit %d):\n %s\n%s%s" % (what, r.returncode, " ".join(cmd), r.stdout[-3000:], r.stderr[-3000:])) return r.stdout def ascii_slug(s): s = s.replace("ł", "l").replace("Ł", "L") s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().lower() return re.sub(r"[^a-z0-9]+", "_", s).strip("_") def default_package(course_id): seg = ascii_slug(course_id)[:40].strip("_") or "course" if seg[0].isdigit() or seg in JAVA_KEYWORDS: seg = "c_" + seg return "net.booktocourse." + seg def valid_package(p): parts = p.split(".") return len(parts) >= 2 and all(re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_]*", x) and x not in JAVA_KEYWORDS for x in parts) def course_meta(course): meta = {} cj = os.path.join(course, "content", "course.json") if os.path.exists(cj): try: meta = json.load(open(cj, encoding="utf-8")) except ValueError: pass if not meta.get("title"): # learner package without content/ — read it from data.js try: txt = open(os.path.join(course, "course", "data.js"), encoding="utf-8").read(200000) m = re.search(r'"meta"\s*:\s*\{.*?"id"\s*:\s*"([^"]+)".*?"title"\s*:\s*"([^"]+)"', txt, re.S) if m: meta.setdefault("id", m.group(1)) meta["title"] = json.loads('"%s"' % m.group(2)) except (OSError, ValueError): pass name = os.path.basename(course.rstrip(os.sep)) meta.setdefault("id", ascii_slug(name) or "course") meta.setdefault("title", name) return meta def stage_course(course, out, dst): n = size = 0 out_real = os.path.realpath(out) for entry in sorted(os.listdir(course)): src = os.path.join(course, entry) if entry in SKIP_TOP or entry.startswith(".") or entry.endswith(".zip") or os.path.realpath(src) == out_real: continue if os.path.isdir(src): shutil.copytree(src, os.path.join(dst, entry), ignore=shutil.ignore_patterns("__pycache__", ".*", "*.pyc", "*.tmp")) else: shutil.copy2(src, os.path.join(dst, entry)) for base, _, files in os.walk(dst): for f in files: n += 1 size += os.path.getsize(os.path.join(base, f)) return n, size def ensure_debug_keystore(env, jdk): if os.path.exists(DEBUG_KS): return os.makedirs(os.path.dirname(DEBUG_KS), exist_ok=True) keytool = os.path.join(jdk["home"], "bin", toolchain.exe("keytool")) run([keytool, "-genkeypair", "-keystore", DEBUG_KS, "-storepass", "android", "-alias", "androiddebugkey", "-keypass", "android", "-keyalg", "RSA", "-keysize", "2048", "-validity", "10950", "-dname", "CN=Android Debug,O=Android,C=US"], env, "keytool (creating ~/.android/debug.keystore)") print("Created the Android debug key: " + DEBUG_KS) def adb_install(adb, apk, env): if not adb: die("--install needs adb (Android SDK platform-tools) — or copy the APK to the device by hand.") out = run([adb, "devices"], env, "adb devices") devs = [l.split()[0] for l in out.splitlines()[1:] if l.strip().endswith("device")] if len(devs) != 1: die("--install needs exactly one connected device with USB debugging on (adb sees %d). APK is ready: %s" % (len(devs), apk)) r = subprocess.run([adb, "-s", devs[0], "install", "-r", apk], env=env, capture_output=True, text=True) text = r.stdout + r.stderr if "INSTALL_FAILED_UPDATE_INCOMPATIBLE" in text: die("the device has this app signed with a different key. Uninstall it first (this deletes the learner's " "progress on that device), then install again:\n %s uninstall <package>" % adb) if r.returncode != 0 or "Success" not in text: die("adb install failed:\n" + text) print("Installed on device " + devs[0]) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("course_dir") ap.add_argument("--out") ap.add_argument("--package") ap.add_argument("--label") ap.add_argument("--version-name") ap.add_argument("--keystore") ap.add_argument("--ks-pass") ap.add_argument("--key-alias") ap.add_argument("--key-pass") ap.add_argument("--install", action="store_true") a = ap.parse_args() course = os.path.abspath(a.course_dir).rstrip(os.sep) for need in ("index.html", os.path.join("course", "data.js"), os.path.join("assets", "app.js")): if not os.path.exists(os.path.join(course, need)): die("%s has no %s — is this a book-to-course course? (run build_course.py first if data.js is missing)" % (course, need)) info = toolchain.detect() if not info["ready"]: print(toolchain.describe(info)) sys.exit(1) env = toolchain.tool_env(info) jdk, sdk = info["jdk"], info["sdk"] bt = sdk["build_tools"]["dir"] android_jar, api = sdk["platform"]["jar"], sdk["platform"]["api"] out = os.path.abspath(a.out or course + "-android") os.makedirs(out, exist_ok=True) meta = course_meta(course) # ---- apk.json: identity that must stay stable between builds ------------------------------ cfg_path = os.path.join(out, "apk.json") cfg = json.load(open(cfg_path, encoding="utf-8")) if os.path.exists(cfg_path) else {} if a.package and cfg.get("package") and a.package != cfg["package"]: print("NOTE: package changes %s → %s. Android treats this as a different app: progress in the old one is not carried over." % (cfg["package"], a.package)) cfg["package"] = a.package or cfg.get("package") or default_package(meta["id"]) if not valid_package(cfg["package"]): die("invalid package id %r — use something like pl.example.gocourse (letters/digits/_, at least two parts)" % cfg["package"]) cfg["label"] = a.label or cfg.get("label") or meta["title"] cfg["version_code"] = int(cfg.get("version_code", 0)) + 1 cfg["version_name"] = a.version_name or "1.0.%d" % cfg["version_code"] cfg.setdefault("icon_color", ICON_COLORS[int(hashlib.sha1(meta["id"].encode()).hexdigest(), 16) % len(ICON_COLORS)]) if a.keystore: cfg["keystore"] = os.path.abspath(a.keystore) ks = cfg.get("keystore") or DEBUG_KS # ---- stage -------------------------------------------------------------------------------- build = os.path.join(out, "build") shutil.rmtree(build, ignore_errors=True) os.makedirs(build) shutil.copytree(os.path.join(TEMPLATE, "res"), os.path.join(build, "res")) with open(os.path.join(build, "res", "values", "icon.xml"), "w", encoding="utf-8") as f: f.write('<?xml version="1.0" encoding="utf-8"?>\n<resources>\n <color name="icon_bg">%s</color>\n</resources>\n' % cfg["icon_color"]) manifest = open(os.path.join(TEMPLATE, "AndroidManifest.xml"), encoding="utf-8").read() # aapt2 reads @ and ? at the start of a label as references label = escape(cfg["label"], {'"': """}) if label[:1] in "@?": label = "\\" + label manifest = manifest.replace("{{PACKAGE}}", cfg["package"]).replace("{{LABEL}}", label) with open(os.path.join(build, "AndroidManifest.xml"), "w", encoding="utf-8") as f: f.write(manifest) assets_dir = os.path.join(build, "assets") nfiles, nbytes = stage_course(course, out, os.path.join(assets_dir, "course")) # ---- resources + manifest + assets → unsigned apk ----------------------------------------- aapt2 = os.path.join(bt, toolchain.exe("aapt2")) res_zip = os.path.join(build, "res.zip") run([aapt2, "compile", "--dir", os.path.join(build, "res"), "-o", res_zip], env, "aapt2 compile") unsigned = os.path.join(build, "unsigned.apk") run([aapt2, "link", "-o", unsigned, "-I", android_jar, "--manifest", os.path.join(build, "AndroidManifest.xml"), "-A", assets_dir, "--min-sdk-version", str(MIN_SDK), "--target-sdk-version", str(api), "--version-code", str(cfg["version_code"]), "--version-name", cfg["version_name"], res_zip], env, "aapt2 link") # ---- java → dex --------------------------------------------------------------------------- classes = os.path.join(build, "classes") sources = glob.glob(os.path.join(TEMPLATE, "src", "**", "*.java"), recursive=True) javac = os.path.join(jdk["home"], "bin", toolchain.exe("javac")) run([javac, "--release", "11", "-classpath", android_jar, "-encoding", "UTF-8", "-nowarn", "-d", classes] + sources, env, "javac") dex_dir = os.path.join(build, "dex") os.makedirs(dex_dir) class_files = glob.glob(os.path.join(classes, "**", "*.class"), recursive=True) run([os.path.join(bt, toolchain.script("d8")), "--release", "--min-api", str(MIN_SDK), "--lib", android_jar, "--output", dex_dir] + class_files, env, "d8") with zipfile.ZipFile(unsigned, "a", zipfile.ZIP_DEFLATED) as z: z.write(os.path.join(dex_dir, "classes.dex"), "classes.dex") # ---- align + sign ------------------------------------------------------------------------- aligned = os.path.join(build, "aligned.apk") run([os.path.join(bt, toolchain.exe("zipalign")), "-f", "-p", "4", unsigned, aligned], env, "zipalign") if ks == DEBUG_KS: ensure_debug_keystore(env, jdk) ks_pass, alias, key_pass = "android", "androiddebugkey", "android" else: ks_pass = a.ks_pass or os.environ.get("APK_KS_PASS") alias = a.key_alias or cfg.get("key_alias") key_pass = a.key_pass or os.environ.get("APK_KEY_PASS") or ks_pass if not (ks_pass and alias): die("own keystore needs --ks-pass (or APK_KS_PASS) and --key-alias") cfg["key_alias"] = alias apk = os.path.join(out, (ascii_slug(meta["id"]).replace("_", "-") or "course") + ".apk") apksigner = os.path.join(bt, toolchain.script("apksigner")) run([apksigner, "sign", "--ks", ks, "--ks-pass", "pass:" + ks_pass, "--ks-key-alias", alias, "--key-pass", "pass:" + key_pass, "--out", apk, aligned], env, "apksigner sign") certs = run([apksigner, "verify", "--print-certs", apk], env, "apksigner verify") if os.path.exists(apk + ".idsig"): os.remove(apk + ".idsig") with open(cfg_path, "w", encoding="utf-8") as f: json.dump(cfg, f, indent=2, ensure_ascii=False) f.write("\n") shutil.rmtree(build, ignore_errors=True) digest = re.search(r"SHA-256 digest: (\w+)", certs) subject = re.search(r"certificate DN: (.+)", certs) print("APK: %s (%d KB)" % (apk, os.path.getsize(apk) // 1024)) print("App: %s · %s · version %s (code %d)" % (cfg["label"], cfg["package"], cfg["version_name"], cfg["version_code"])) print("Android: %s+ (minSdk %d), targetSdk %d" % ("7.0", MIN_SDK, api)) print("Course: %d files, %d KB" % (nfiles, nbytes // 1024)) print("Signed: %s [%s] cert SHA-256 %s" % (ks, subject.group(1).strip() if subject else "?", digest.group(1)[:16] + "…" if digest else "?")) if os.path.isdir(os.path.join(course, "exercises")) and os.listdir(os.path.join(course, "exercises")): print("Note: the course has code exercises — their 'Run tests' button needs a computer (start.sh); " "on the phone the page shows the test command instead.") if a.install: adb_install(sdk["adb"], apk, env) if __name__ == "__main__": main()course-to-apk/scripts/setup_toolchain.py11 KBe3dd258465b5upstream ↗
#!/usr/bin/env python3 """Download whatever toolchain.py reports as missing — without sudo, into one folder of the user's home. Usage: setup_toolchain.py --dry-run # print what would be downloaded, from where, how big — download nothing setup_toolchain.py --show-license # print the Android SDK licence text (show it to the user) setup_toolchain.py --accept-licenses # download + install (only the missing parts) Installs into ~/.local/share/course-to-apk (%LOCALAPPDATA%\\course-to-apk on Windows); set COURSE_TO_APK_HOME to change it (toolchain.py and build_apk.py read the same variable, so they find what was installed). Sources (official only, checksums verified): JDK 21 Eclipse Temurin via https://api.adoptium.net (~200 MB download) build-tools https://dl.google.com/android/repository/ (~62 MB) — newest stable, listed in repository2-3.xml platform https://dl.google.com/android/repository/ (~65 MB) — newest stable android-NN --accept-licenses means the USER agreed to the Android SDK License Agreement (https://developer.android.com/studio/terms). Only pass it after they said yes. Standard library only. """ import argparse import hashlib import json import os import shutil import stat import sys import tarfile import tempfile import urllib.request import xml.etree.ElementTree as ET import zipfile sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import toolchain # noqa: E402 REPO = "https://dl.google.com/android/repository/" REPO_XML = REPO + "repository2-3.xml" JDK_FEATURE = 21 UA = {"User-Agent": "course-to-apk/1.0"} def fetch(url): with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=60) as r: return r.read() def host_os(): return "windows" if os.name == "nt" else "macosx" if sys.platform == "darwin" else "linux" def host_arch(): m = toolchain.platform.machine().lower() return "aarch64" if m in ("arm64", "aarch64") else "x64" # ---- plan ---------------------------------------------------------------------------------------- def plan_jdk(): os_name = {"windows": "windows", "macosx": "mac", "linux": "linux"}[host_os()] url = ("https://api.adoptium.net/v3/assets/latest/%d/hotspot?os=%s&architecture=%s&image_type=jdk&vendor=eclipse" % (JDK_FEATURE, os_name, host_arch())) data = json.loads(fetch(url)) if not data: raise SystemExit("Adoptium has no JDK %d build for %s/%s — install a JDK by hand." % (JDK_FEATURE, os_name, host_arch())) pkg = data[0]["binary"]["package"] return {"what": "JDK %s (Eclipse Temurin)" % data[0]["version"]["semver"], "kind": "jdk", "url": pkg["link"], "size": pkg["size"], "hash": ("sha256", pkg["checksum"]), "name": pkg["name"]} def _stable(path): return not any(x in path for x in ("-rc", "beta", "-ext", "preview")) def plan_sdk(need_bt, need_pl): root = ET.fromstring(fetch(REPO_XML)) licenses = {l.get("id"): (l.text or "") for l in root.findall("license")} best_bt = best_pl = None for p in root.findall("remotePackage"): path = p.get("path") if p.find("channelRef") is not None and p.find("channelRef").get("ref") != "channel-0": continue if not _stable(path): continue if path.startswith("build-tools;"): ver = path.split(";", 1)[1] key = toolchain._version_key(ver) if key[0] < toolchain.MIN_BUILD_TOOLS: continue arc = next((a for a in p.findall("archives/archive") if a.findtext("host-os") == host_os()), None) if arc is not None and (best_bt is None or key > best_bt[0]): best_bt = (key, path, ver, arc, p) elif path.startswith("platforms;android-") and path.split("-", 1)[1].isdigit(): api = int(path.split("-", 1)[1]) arcs = p.findall("archives/archive") if api >= toolchain.MIN_PLATFORM and arcs and (best_pl is None or api > best_pl[0]): best_pl = (api, path, "android-%d" % api, arcs[0], p) items = [] for need, best, kind in ((need_bt, best_bt, "build-tools"), (need_pl, best_pl, "platform")): if not need: continue if not best: raise SystemExit("No suitable %s found in %s" % (kind, REPO_XML)) _, path, folder, arc, pkg = best cs = arc.find("complete/checksum") lic = pkg.find("uses-license").get("ref") items.append({"what": path, "kind": kind, "folder": folder, "url": REPO + arc.findtext("complete/url"), "size": int(arc.findtext("complete/size")), "hash": ((cs.get("type") or "sha1").replace("-", ""), cs.text.strip()), "license_id": lic, "license_text": licenses.get(lic, "")}) return items def make_plan(info): items = [] if "jdk" in info["missing"]: items.append(plan_jdk()) if "build-tools" in info["missing"] or "platform" in info["missing"]: items += plan_sdk("build-tools" in info["missing"], "platform" in info["missing"]) return items # ---- install ------------------------------------------------------------------------------------- def download(item, tmp): dest = os.path.join(tmp, item["url"].rsplit("/", 1)[-1].split("?")[0] or "download") algo, want = item["hash"] h = hashlib.new(algo) req = urllib.request.Request(item["url"], headers=UA) done, last = 0, -1 with urllib.request.urlopen(req, timeout=120) as r, open(dest, "wb") as f: while True: chunk = r.read(1 << 20) if not chunk: break f.write(chunk) h.update(chunk) done += len(chunk) pct = done * 100 // max(item["size"], 1) if pct // 10 != last: last = pct // 10 print(" %s %d%% (%d / %d MB)" % (item["what"], pct, done >> 20, item["size"] >> 20), flush=True) if h.hexdigest().lower() != want.lower(): raise SystemExit("Checksum mismatch for %s — download corrupted or tampered with; nothing installed." % item["url"]) return dest def _single_top(names): tops = {n.split("/", 1)[0] for n in names if n.strip("/")} return tops.pop() if len(tops) == 1 else None def extract_zip(path, target): """Extract stripping the archive's single top folder; keep exec bits and symlinks (zipfile drops both).""" with zipfile.ZipFile(path) as z: top = _single_top(z.namelist()) for info in z.infolist(): rel = info.filename[len(top) + 1:] if top else info.filename if not rel or rel.endswith("/"): continue out = os.path.normpath(os.path.join(target, rel)) if not out.startswith(os.path.normpath(target) + os.sep): continue os.makedirs(os.path.dirname(out), exist_ok=True) mode = info.external_attr >> 16 if stat.S_ISLNK(mode) and os.name != "nt": if os.path.lexists(out): os.remove(out) os.symlink(z.read(info).decode(), out) continue with z.open(info) as src, open(out, "wb") as dst: shutil.copyfileobj(src, dst) if mode & 0o111: os.chmod(out, 0o755) def install_jdk(item, archive, dest): tmp = tempfile.mkdtemp(dir=dest) if archive.endswith((".tar.gz", ".tgz")): with tarfile.open(archive) as t: t.extractall(tmp, filter="tar") if hasattr(tarfile, "data_filter") else t.extractall(tmp) else: with zipfile.ZipFile(archive) as z: z.extractall(tmp) top = os.path.join(tmp, os.listdir(tmp)[0]) final = os.path.join(dest, "jdk-%d" % JDK_FEATURE) if os.path.exists(final): shutil.rmtree(final) os.rename(top, final) shutil.rmtree(tmp, ignore_errors=True) if os.path.isdir(os.path.join(final, "Contents", "Home")): # macOS bundle layout return os.path.join(final, "Contents", "Home") if os.name != "nt": for b in os.listdir(os.path.join(final, "bin")): os.chmod(os.path.join(final, "bin", b), 0o755) return final def install_sdk_pkg(item, archive, sdk): sub = "build-tools" if item["kind"] == "build-tools" else "platforms" target = os.path.join(sdk, sub, item["folder"]) if os.path.exists(target): shutil.rmtree(target) os.makedirs(target) extract_zip(archive, target) # same marker sdkmanager writes, so Android Studio / sdkmanager also treat the licence as accepted lic_dir = os.path.join(sdk, "licenses") os.makedirs(lic_dir, exist_ok=True) lic_file = os.path.join(lic_dir, item["license_id"]) digest = hashlib.sha1(item["license_text"].encode("utf-8")).hexdigest() existing = open(lic_file).read().split() if os.path.exists(lic_file) else [] if digest not in existing: with open(lic_file, "a") as f: f.write("\n" + digest) return target def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--show-license", action="store_true") ap.add_argument("--accept-licenses", action="store_true") a = ap.parse_args() info = toolchain.detect() if info["ready"] and not a.show_license: print(toolchain.describe(info)) print("\nNothing to install.") return if a.show_license: root = ET.fromstring(fetch(REPO_XML)) print(next(l.text for l in root.findall("license") if l.get("id") == "android-sdk-license")) return dest = toolchain.OWN_DIR plan = make_plan(info) total = sum(i["size"] for i in plan) print("Missing: %s. Will download (%d MB total) into %s:" % (", ".join(info["missing"]), total >> 20, dest)) for i in plan: print(" - %-34s %4d MB %s" % (i["what"], i["size"] >> 20, i["url"])) if a.dry_run: if any(i["kind"] != "jdk" for i in plan): print("\nAndroid SDK packages require accepting the Android SDK License Agreement: https://developer.android.com/studio/terms") return if any(i["kind"] != "jdk" for i in plan) and not a.accept_licenses: raise SystemExit("\nRefusing to download Android SDK packages without --accept-licenses " "(ask the user to accept https://developer.android.com/studio/terms first).") os.makedirs(dest, exist_ok=True) sdk = os.path.join(dest, "sdk") with tempfile.TemporaryDirectory(dir=dest) as tmp: for item in plan: archive = download(item, tmp) if item["kind"] == "jdk": print(" installed JDK → " + install_jdk(item, archive, dest)) else: print(" installed %s → %s" % (item["what"], install_sdk_pkg(item, archive, sdk))) os.remove(archive) print() after = toolchain.detect() print(toolchain.describe(after)) sys.exit(0 if after["ready"] else 1) if __name__ == "__main__": main()course-to-apk/scripts/toolchain.py13 KB6d557a20cd2aupstream ↗
#!/usr/bin/env python3 """Find the tools needed to build a course APK, and say exactly what is missing. Usage: toolchain.py [--json] Needed (no Gradle, no Android Studio): * JDK 17+ javac, keytool (java for d8/apksigner) * Android SDK with build-tools 34+ aapt2, d8, zipalign, apksigner platforms;android-NN (NN >= 34) android.jar * optional: platform-tools (adb) — only to install straight onto a connected device Exit code 0 = ready to build, 1 = something is missing (the report says what and where to get it). Tools are searched in the usual places even when they are not on PATH (JAVA_HOME, ANDROID_HOME, ~/Android/Sdk, ~/.jdks, Android Studio's bundled JBR, /usr/lib/jvm, …) and in the folder setup_toolchain.py installs into. """ import glob import json import os import platform import re import shutil import subprocess import sys MIN_JDK = 17 MIN_BUILD_TOOLS = 34 MIN_PLATFORM = 34 WIN = os.name == "nt" HOME = os.path.expanduser("~") # setup_toolchain.py installs here; nothing outside this folder (and ~/.android for the debug key) is touched. OWN_DIR = os.environ.get("COURSE_TO_APK_HOME") or ( os.path.join(os.environ.get("LOCALAPPDATA", HOME), "course-to-apk") if WIN else os.path.join(HOME, ".local", "share", "course-to-apk")) def exe(name): return name + ".exe" if WIN else name def script(name): return name + ".bat" if WIN else name def _version_key(s): return [int(x) for x in re.findall(r"\d+", s)] or [0] # ---- JDK ----------------------------------------------------------------------------------------- def _jdk_major(java_home): rel = os.path.join(java_home, "release") if os.path.exists(rel): m = re.search(r'JAVA_VERSION="(\d+)(?:\.(\d+))?', open(rel, encoding="utf-8", errors="replace").read()) if m: return 8 if m.group(1) == "1" else int(m.group(1)) try: out = subprocess.run([os.path.join(java_home, "bin", exe("javac")), "-version"], capture_output=True, text=True, timeout=30) m = re.search(r"javac (\d+)(?:\.(\d+))?", out.stdout + out.stderr) if m: return 8 if m.group(1) == "1" else int(m.group(1)) except (OSError, subprocess.SubprocessError): pass return 0 def _jdk_candidates(): seen = [] def add(p): if not p: return p = os.path.realpath(p) if p not in seen and os.path.exists(os.path.join(p, "bin", exe("javac"))): seen.append(p) add(os.environ.get("JAVA_HOME")) for tool in ("javac", "java"): w = shutil.which(tool) if w: add(os.path.dirname(os.path.dirname(os.path.realpath(w)))) pats = [os.path.join(OWN_DIR, "jdk*"), os.path.join(HOME, ".jdks", "*"), os.path.join(HOME, ".sdkman", "candidates", "java", "*"), os.path.join(HOME, ".asdf", "installs", "java", "*"), "/usr/lib/jvm/*", "/usr/java/*", "/opt/jdk*", "/opt/java/*", # Android Studio ships a full JDK ("JBR") "/opt/android-studio/jbr", os.path.join(HOME, "android-studio", "jbr"), os.path.join(HOME, ".local", "share", "JetBrains", "Toolbox", "apps", "android-studio*", "jbr"), os.path.join(HOME, ".local", "share", "JetBrains", "Toolbox", "apps", "*", "*", "jbr"), "/snap/android-studio/current/jbr", "/Library/Java/JavaVirtualMachines/*/Contents/Home", os.path.join(HOME, "Library", "Java", "JavaVirtualMachines", "*", "Contents", "Home"), "/Applications/Android Studio.app/Contents/jbr/Contents/Home", "/opt/homebrew/opt/openjdk*/libexec/openjdk.jdk/Contents/Home"] if WIN: for base in (os.environ.get("ProgramFiles", r"C:\Program Files"),): pats += [os.path.join(base, v, "*") for v in ("Java", "Eclipse Adoptium", "Microsoft", "Zulu", "Android\\Android Studio")] pats.append(os.path.join(base, "Android", "Android Studio", "jbr")) for pat in pats: for p in sorted(glob.glob(pat)): add(p) return seen def find_jdk(): """Newest JDK >= MIN_JDK with javac and keytool, or (None, [found-but-too-old]).""" good, old = [], [] for home in _jdk_candidates(): major = _jdk_major(home) if not os.path.exists(os.path.join(home, "bin", exe("keytool"))): continue (good if major >= MIN_JDK else old).append((major, home)) if good: major, home = max(good) return {"home": home, "major": major}, old return None, old # ---- Android SDK --------------------------------------------------------------------------------- def _sdk_candidates(): c = [os.environ.get("ANDROID_HOME"), os.environ.get("ANDROID_SDK_ROOT"), os.path.join(OWN_DIR, "sdk")] if WIN: c.append(os.path.join(os.environ.get("LOCALAPPDATA", ""), "Android", "Sdk")) elif sys.platform == "darwin": c.append(os.path.join(HOME, "Library", "Android", "sdk")) else: c += [os.path.join(HOME, "Android", "Sdk"), "/opt/android-sdk", "/usr/lib/android-sdk", "/opt/android-sdk-linux"] for tool in ("adb", "sdkmanager", "aapt2"): w = shutil.which(tool) if w: # <sdk>/platform-tools/adb, <sdk>/cmdline-tools/latest/bin/sdkmanager, <sdk>/build-tools/X/aapt2 p = os.path.realpath(w) for up in (2, 3, 4): c.append(os.path.abspath(os.path.join(p, *[".."] * up))) out = [] for p in c: if p and os.path.isdir(p) and os.path.realpath(p) not in out and ( os.path.isdir(os.path.join(p, "build-tools")) or os.path.isdir(os.path.join(p, "platforms")) or os.path.isdir(os.path.join(p, "platform-tools")) or os.path.isdir(os.path.join(p, "cmdline-tools"))): out.append(os.path.realpath(p)) return out def _build_tools(sdk): need = [exe("aapt2"), script("d8"), exe("zipalign"), script("apksigner")] best = None for d in glob.glob(os.path.join(sdk, "build-tools", "*")): v = os.path.basename(d) if _version_key(v)[0] < MIN_BUILD_TOOLS or not all(os.path.exists(os.path.join(d, n)) for n in need): continue if best is None or _version_key(v) > _version_key(best[0]): best = (v, d) return best def _api_level(platform_dir): props = os.path.join(platform_dir, "source.properties") if os.path.exists(props): m = re.search(r"AndroidVersion\.ApiLevel=(\d+)", open(props, encoding="utf-8", errors="replace").read()) if m: return int(m.group(1)) m = re.match(r"android-(\d+)", os.path.basename(platform_dir)) return int(m.group(1)) if m else 0 def _platform(sdk): best = None for d in glob.glob(os.path.join(sdk, "platforms", "android-*")): if not os.path.exists(os.path.join(d, "android.jar")): continue api = _api_level(d) key = (api, _version_key(os.path.basename(d))) if api >= MIN_PLATFORM and (best is None or key > best[0]): best = (key, d) return {"api": best[0][0], "dir": best[1], "jar": os.path.join(best[1], "android.jar")} if best else None def find_sdk(): """Best SDK = the one that has both usable build-tools and a platform; otherwise the most complete one.""" found = [] for sdk in _sdk_candidates(): bt, pl = _build_tools(sdk), _platform(sdk) adb = os.path.join(sdk, "platform-tools", exe("adb")) sdkm = None for cand in (os.path.join(sdk, "cmdline-tools", "latest", "bin", script("sdkmanager")), *sorted(glob.glob(os.path.join(sdk, "cmdline-tools", "*", "bin", script("sdkmanager"))), reverse=True), os.path.join(sdk, "tools", "bin", script("sdkmanager"))): if os.path.exists(cand): sdkm = cand break found.append({"root": sdk, "build_tools": {"version": bt[0], "dir": bt[1]} if bt else None, "platform": pl, "adb": adb if os.path.exists(adb) else shutil.which("adb"), "sdkmanager": sdkm}) found.sort(key=lambda s: (bool(s["build_tools"]) and bool(s["platform"]), bool(s["build_tools"]), bool(s["platform"]), bool(s["sdkmanager"])), reverse=True) return found[0] if found else None # ---- report -------------------------------------------------------------------------------------- def detect(): jdk, old_jdks = find_jdk() sdk = find_sdk() missing = [] if not jdk: missing.append("jdk") if not sdk or not sdk["build_tools"]: missing.append("build-tools") if not sdk or not sdk["platform"]: missing.append("platform") return {"ready": not missing, "missing": missing, "jdk": jdk, "old_jdks": [{"major": m, "home": h} for m, h in old_jdks], "sdk": sdk, "os": sys.platform, "arch": platform.machine(), "install_dir": OWN_DIR} def tool_env(info): """Environment for running d8/apksigner (they are scripts that need java on PATH / JAVA_HOME).""" env = dict(os.environ) if info.get("jdk"): env["JAVA_HOME"] = info["jdk"]["home"] env["PATH"] = os.path.join(info["jdk"]["home"], "bin") + os.pathsep + env.get("PATH", "") if info.get("sdk"): env["ANDROID_HOME"] = info["sdk"]["root"] return env def install_help(info): """Human instructions: what to download and where from, for whatever is missing.""" lines = [] osname = info["os"] if "jdk" in info["missing"]: lines.append("JDK %d or newer (javac + keytool):" % MIN_JDK) if info["old_jdks"]: lines.append(" found only older: " + ", ".join("JDK %d at %s" % (j["major"], j["home"]) for j in info["old_jdks"])) lines.append(" download: https://adoptium.net/temurin/releases/?version=21 (Eclipse Temurin 21 LTS, .tar.gz/.zip/.msi/.pkg)") if osname.startswith("linux"): lines.append(" or package manager: Arch/CachyOS `sudo pacman -S jdk21-openjdk` · Debian/Ubuntu `sudo apt install openjdk-21-jdk-headless`" " · Fedora `sudo dnf install java-21-openjdk-devel`") elif osname == "darwin": lines.append(" or Homebrew: `brew install --cask temurin@21`") elif osname.startswith("win"): lines.append(" or: `winget install EclipseAdoptium.Temurin.21.JDK`") lines.append(" (Android Studio users already have one in <Android Studio>/jbr — set JAVA_HOME to it)") if "build-tools" in info["missing"] or "platform" in info["missing"]: sdk = info["sdk"] lines.append("Android SDK packages: build-tools;%d.0.0 (or newer) and platforms;android-35 (or newer):" % 35) if sdk and sdk["sdkmanager"]: lines.append(" you already have sdkmanager — run:") lines.append(' "%s" --sdk_root="%s" "build-tools;35.0.0" "platforms;android-35"' % (sdk["sdkmanager"], sdk["root"])) else: lines.append(" 1) 'Command line tools only' from https://developer.android.com/studio#command-line-tools-only") lines.append(" unzip so that the path is <SDK>/cmdline-tools/latest/bin/sdkmanager (suggested <SDK>: %s)" % (sdk["root"] if sdk else ("%%LOCALAPPDATA%%\\Android\\Sdk" if osname.startswith("win") else "~/Library/Android/sdk" if osname == "darwin" else "~/Android/Sdk"))) lines.append(' 2) <SDK>/cmdline-tools/latest/bin/sdkmanager --sdk_root=<SDK> "build-tools;35.0.0" "platforms;android-35" "platform-tools"') lines.append(" (accept the Android SDK licence when asked; needs the JDK above)") lines.append(" or install Android Studio (https://developer.android.com/studio) — its SDK Manager installs the same packages.") lines.append(" then, if the SDK is not in a standard place, set ANDROID_HOME to it.") return lines def describe(info): lines = [] j = info["jdk"] lines.append("JDK: " + ("OK JDK %d %s" % (j["major"], j["home"]) if j else "MISSING")) s = info["sdk"] lines.append("Android SDK: " + (s["root"] if s else "MISSING")) if s: bt, pl = s["build_tools"], s["platform"] lines.append(" build-tools " + ("OK %s" % bt["version"] if bt else "MISSING (need %d+ with aapt2, d8, zipalign, apksigner)" % MIN_BUILD_TOOLS)) lines.append(" platform " + ("OK API %d %s" % (pl["api"], os.path.basename(pl["dir"])) if pl else "MISSING (need android-%d+)" % MIN_PLATFORM)) lines.append(" adb " + ("OK %s" % s["adb"] if s["adb"] else "not installed (optional — only for installing over USB)")) lines.append("") if info["ready"]: lines.append("READY — build_apk.py can build the APK.") else: lines.append("NOT READY — missing: " + ", ".join(info["missing"])) lines.append("") lines += install_help(info) lines.append("") lines.append("Automatic alternative (no sudo, installs only into %s; downloads ~%s):" % ( info["install_dir"], "350 MB" if "jdk" in info["missing"] else "150 MB")) lines.append(" python3 setup_toolchain.py --accept-licenses") return "\n".join(lines) def main(): info = detect() if "--json" in sys.argv[1:]: info["help"] = install_help(info) if not info["ready"] else [] print(json.dumps(info, indent=2)) else: print(describe(info)) sys.exit(0 if info["ready"] else 1) if __name__ == "__main__": main()course-to-apk/references/troubleshooting.md2.8 KB4a24cabc8673upstream ↗
# Troubleshooting course-to-apk ## Toolchain - **JDK installed but reported missing** — it may be a JRE (no `javac`/`keytool`) or older than 17 (the report lists too-old ones). Point `JAVA_HOME` at a JDK 17+ folder (the one containing `bin/javac`). - **SDK in an unusual place** — set `ANDROID_HOME` to the folder that contains `build-tools/` and `platforms/`. - **Only old build-tools / platform** — need build-tools 34+ and a platform android-34+; `toolchain.py` prints the `sdkmanager` command when sdkmanager exists, otherwise `setup_toolchain.py` can fetch them. - **`setup_toolchain.py` checksum mismatch** — the download was interrupted or altered; run it again. Nothing is installed from a file that fails the check. - **Behind a proxy** — Python's urllib honours `HTTPS_PROXY`; set it before running `setup_toolchain.py`. - **Windows** — the scripts run with `py scripts\...`; tool names `.bat`/`.exe` are handled. `setup_toolchain.py` installs into `%LOCALAPPDATA%\course-to-apk`. ## Build - **`aapt2 link` error about the label** — unusual characters in the course title; pass a simpler `--label`. - **`javac` error** — usually a JDK older than 17 picked up via `JAVA_HOME`; check the JDK line of `toolchain.py`. - **APK very large** — the course folder contains big media next to `index.html`. Everything except `content/`, `exercises/`, `progress/`, `.build/`, start scripts, README and zips is packed; move unrelated files out. ## Install on the device - **"App not installed" / `INSTALL_FAILED_UPDATE_INCOMPATIBLE`** — an older build was signed with a different key (e.g. built on another computer, whose `~/.android/debug.keystore` differs). Uninstall the old app first (its progress is lost), or rebuild with the original key: copy that computer's `debug.keystore`, or use `--keystore`. To build the same app from several computers, use one shared keystore. - **"Install blocked" / Play Protect** — sideloaded debug-signed apps trigger a warning; choose "Install anyway". Allow "install unknown apps" for the file manager / browser that opens the APK. - **`INSTALL_FAILED_OLDER_SDK`** — the device runs Android older than 7.0; not supported. - **`adb` sees no device** — USB debugging off, cable charge-only, or the authorization prompt on the phone not accepted. `adb devices` must show `device`, not `unauthorized`. ## Running - **Blank page** — `course/data.js` missing in the course folder when the APK was built; rebuild the course (`build_course.py`) and then the APK. - **Progress gone after reinstall** — uninstalling deletes app data; updating (install over) keeps it. Same package id + same key = update. - **Progress from the computer is not on the phone** — they are separate stores (`progress/progress.json` vs the app's local storage); there is no sync.course-to-apk/assets/android/AndroidManifest.xml1.0 KB3cfc5f6713deupstream ↗
<?xml version="1.0" encoding="utf-8"?> <!-- Filled in by build_apk.py: {{PACKAGE}} --> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="{{PACKAGE}}"> <application android:label="{{LABEL}}" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:theme="@style/CourseTheme" android:allowBackup="true" android:supportsRtl="true"> <activity android:name="net.booktocourse.app.MainActivity" android:exported="true" android:launchMode="singleTask" android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden|navigation|density" android:windowSoftInputMode="adjustResize"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest>course-to-apk/assets/android/res/drawable/ic_book.xml510 Bb370a8a7cc63upstream ↗
<?xml version="1.0" encoding="utf-8"?> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108"> <path android:fillColor="@color/icon_fg" android:pathData="M54,38 C47,33 38,32 30,33 L30,73 C38,72 47,73 54,78 Z" /> <path android:fillColor="@color/icon_fg" android:fillAlpha="0.82" android:pathData="M54,38 C61,33 70,32 78,33 L78,73 C70,72 61,73 54,78 Z" /> </vector>course-to-apk/assets/android/res/mipmap-anydpi-v26/ic_launcher.xml296 Bf2d1d09fd488upstream ↗
<?xml version="1.0" encoding="utf-8"?> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <background android:drawable="@color/icon_bg" /> <foreground android:drawable="@drawable/ic_book" /> <monochrome android:drawable="@drawable/ic_book" /> </adaptive-icon>course-to-apk/assets/android/res/mipmap/ic_launcher.xml305 B66a993613995upstream ↗
<?xml version="1.0" encoding="utf-8"?> <layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <item> <shape android:shape="oval"> <solid android:color="@color/icon_bg" /> </shape> </item> <item android:drawable="@drawable/ic_book" /> </layer-list>course-to-apk/assets/android/res/values-night/colors.xml108 Bc89c4d2fc7dfupstream ↗
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="page_bg">#FF121212</color> </resources>course-to-apk/assets/android/res/values-night/themes.xml347 Bee571f6cb480upstream ↗
<?xml version="1.0" encoding="utf-8"?> <resources> <style name="CourseTheme" parent="android:Theme.Material.NoActionBar"> <item name="android:windowBackground">@color/page_bg</item> <item name="android:colorPrimary">@color/icon_bg</item> <item name="android:colorAccent">@color/icon_bg</item> </style> </resources>course-to-apk/assets/android/res/values/colors.xml152 Bce6b36671485upstream ↗
<?xml version="1.0" encoding="utf-8"?> <resources> <color name="page_bg">#FFFFFFFF</color> <color name="icon_fg">#FFFFFFFF</color> </resources>course-to-apk/assets/android/res/values/themes.xml442 Bfbc4a13d67b0upstream ↗
<?xml version="1.0" encoding="utf-8"?> <resources> <!-- isLightTheme drives prefers-color-scheme inside the WebView (targetSdk 33+) --> <style name="CourseTheme" parent="android:Theme.Material.Light.NoActionBar"> <item name="android:windowBackground">@color/page_bg</item> <item name="android:colorPrimary">@color/icon_bg</item> <item name="android:colorAccent">@color/icon_bg</item> </style> </resources>course-to-apk/assets/android/src/net/booktocourse/app/MainActivity.java7.9 KB0b73492eed38upstream ↗
package net.booktocourse.app; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.res.AssetManager; import android.graphics.Insets; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.view.View; import android.view.WindowInsets; import android.webkit.WebChromeClient; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.FrameLayout; import android.window.OnBackInvokedCallback; import android.window.OnBackInvokedDispatcher; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.Collections; /** * Minimal WebView wrapper for a book-to-course course. * * The course files live in the APK under assets/course/ and are served from * https://appassets.androidplatform.net/ (a host reserved for exactly this, it never reaches the network). * A real https origin keeps localStorage — and so the learner's progress — stable across app updates, * which file:// URLs do not guarantee. No AndroidX: everything here is framework API. */ public class MainActivity extends Activity { private static final String HOST = "appassets.androidplatform.net"; private static final String ORIGIN = "https://" + HOST + "/"; private static final String START = ORIGIN + "index.html"; private WebView web; private OnBackInvokedCallback backCallback; @Override protected void onCreate(Bundle state) { super.onCreate(state); web = new WebView(this); // WebView ignores its own padding, so the insets go on a plain container around it FrameLayout root = new FrameLayout(this); root.addView(web, new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); setContentView(root); applyInsets(root); WebSettings s = web.getSettings(); s.setJavaScriptEnabled(true); s.setDomStorageEnabled(true); s.setAllowFileAccess(false); s.setAllowContentAccess(false); s.setBuiltInZoomControls(true); s.setDisplayZoomControls(false); s.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); // A WebChromeClient makes alert()/confirm() show real dialogs instead of being silently dropped. web.setWebChromeClient(new WebChromeClient()); web.setWebViewClient(new CourseClient(getAssets())); if (Build.VERSION.SDK_INT >= 33) { backCallback = new OnBackInvokedCallback() { @Override public void onBackInvoked() { goBackOrFinish(); } }; getOnBackInvokedDispatcher().registerOnBackInvokedCallback( OnBackInvokedDispatcher.PRIORITY_DEFAULT, backCallback); } if (state == null || web.restoreState(state) == null) { web.loadUrl(START); } } @Override protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); } @Override @SuppressWarnings("deprecation") public void onBackPressed() { // Android < 13; newer versions use backCallback goBackOrFinish(); } private void goBackOrFinish() { if (web.canGoBack()) { web.goBack(); } else { finish(); } } @Override protected void onPause() { super.onPause(); web.onPause(); } @Override protected void onResume() { super.onResume(); web.onResume(); } @Override protected void onDestroy() { if (Build.VERSION.SDK_INT >= 33 && backCallback != null) { getOnBackInvokedDispatcher().unregisterOnBackInvokedCallback(backCallback); } web.destroy(); super.onDestroy(); } /** Apps targeting Android 15+ are drawn edge-to-edge; pad the page so bars, cutouts and the keyboard never cover it. */ @SuppressWarnings("deprecation") private static void applyInsets(View v) { v.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { @Override public WindowInsets onApplyWindowInsets(View view, WindowInsets in) { if (Build.VERSION.SDK_INT >= 30) { Insets i = in.getInsets(WindowInsets.Type.systemBars() | WindowInsets.Type.displayCutout() | WindowInsets.Type.ime()); view.setPadding(i.left, i.top, i.right, i.bottom); } else { view.setPadding(in.getSystemWindowInsetLeft(), in.getSystemWindowInsetTop(), in.getSystemWindowInsetRight(), in.getSystemWindowInsetBottom()); } return in; } }); } private final class CourseClient extends WebViewClient { private final AssetManager assets; CourseClient(AssetManager assets) { this.assets = assets; } @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest req) { Uri u = req.getUrl(); if (!HOST.equals(u.getHost())) { return null; } String path = u.getPath(); if (path == null || path.equals("/")) { path = "/index.html"; } if (path.contains("..")) { return notFound(); } try { InputStream in = assets.open("course" + path); WebResourceResponse r = new WebResourceResponse(mime(path), "utf-8", in); r.setResponseHeaders(Collections.singletonMap("Cache-Control", "no-cache")); return r; } catch (IOException e) { // e.g. api/ping — the page then falls back to saving progress in localStorage return notFound(); } } @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest req) { Uri u = req.getUrl(); if (HOST.equals(u.getHost())) { return false; } // Links that leave the course (book sites, docs, installers) open in the user's browser. try { startActivity(new Intent(Intent.ACTION_VIEW, u)); } catch (ActivityNotFoundException e) { // nothing can open it — stay on the page } return true; } } private static WebResourceResponse notFound() { return new WebResourceResponse("text/plain", "utf-8", 404, "Not Found", Collections.<String, String>emptyMap(), new ByteArrayInputStream(new byte[0])); } private static String mime(String path) { String p = path.toLowerCase(); if (p.endsWith(".html") || p.endsWith(".htm")) return "text/html"; if (p.endsWith(".js") || p.endsWith(".mjs")) return "text/javascript"; if (p.endsWith(".css")) return "text/css"; if (p.endsWith(".json")) return "application/json"; if (p.endsWith(".svg")) return "image/svg+xml"; if (p.endsWith(".png")) return "image/png"; if (p.endsWith(".jpg") || p.endsWith(".jpeg")) return "image/jpeg"; if (p.endsWith(".gif")) return "image/gif"; if (p.endsWith(".webp")) return "image/webp"; if (p.endsWith(".woff2")) return "font/woff2"; if (p.endsWith(".woff")) return "font/woff"; if (p.endsWith(".ttf")) return "font/ttf"; if (p.endsWith(".mp3")) return "audio/mpeg"; if (p.endsWith(".mp4")) return "video/mp4"; if (p.endsWith(".txt") || p.endsWith(".md")) return "text/plain"; return "application/octet-stream"; } }README.mdupstream ↗
Contents are not inlined in the catalog. Open the upstream file.
Plain-text copy, no login: /skill-bundles/sebastianhaba-book-to-course-skill-course-to-apk.md