⏳ This skill is pending AI review.

Scores will appear once the review pipeline completes.

version unknown

course-to-apk

@sebastianhaba⭐ 76 stars

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.

—/10

// RATINGS

⭐GitHub Stars
⭐⭐ 76 on GitHubGitHub ↗

Growing

🟢ProSkills Score
—
📍

Not yet listed on ClawHub or SkillsMP

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

Demo of a Go course: code walkthrough, quiz, flashcards and passing exercise tests

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

Demo of a math course with rendered formulas, a quiz 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

Course overview with chapters and progress

Interactive quiz

Quiz with answer choices and hints

Coding exercise and test results

Coding exercise with starter code and a Run tests button

How it works

  1. Give the skill a book or document and ask it to create a course. You can specify the course language and scope.
  2. The skill extracts the source, plans lessons by chapter, and builds the course as a folder of local web files.
  3. Open the generated course with start.sh (macOS/Linux) or start.bat (Windows). The local server saves progress to progress/progress.json and 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-apk with 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 with adb.

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-appimage with 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"], {'"': "&quot;"})
        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

// REPO STATS

76 stars