Files
lintunes/andtunes/build.py
T
travandClaude Opus 5 f091a545f5 v0.20.5: the wheel learns to coast
getevent settled what the wheel can and can't tell us: och1970_holl_key
advertises KEY_UP and KEY_DOWN and nothing else -- no REL_WHEEL, no
resolution, because the och1970 is a Hall latch the driver quantises into
clicks. There is no sub-detent position to read without a kernel driver
change, so all the nuance has to come from *when* the detents arrive.

So a detent is now an impulse into a velocity rather than a jump. The list
coasts and eases out, and an impulse is worth up to 8x more when detents come
30 ms apart than when they come 220 ms apart -- squared, so the gain stays out
of the way while you're hunting for one row. Measured on the device: one
unhurried detent moves about half a row, twelve fast ones move forty-odd.
Turning back the other way kills the coast first, so a correction bites
instead of fighting the glide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
2026-09-14 13:51:42 -07:00

156 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Build the andTunes APK without Gradle.
Everything an app with no dependencies needs is already in the Android SDK:
aapt2 packages resources, javac compiles against the platform's android.jar,
R8 shrinks and dexes, zipalign + apksigner finish the file. Gradle and the
Android Gradle Plugin are a ~1 GB download that would do the same five steps
— and andTunes links nothing in, which is the point of it (cold start is the
feature), so there is nothing for a dependency manager to manage.
python3 andtunes/build.py # -> andtunes/build/andTunes.apk
python3 andtunes/build.py --install # ... then adb install -r + launch
python3 andtunes/build.py --ship # ... then copy into lintunes/android/
The source tree is the standard Gradle shape (app/src/main/{java,res,
AndroidManifest.xml}), so a Gradle build could be dropped in later with no
moves. Stdlib only.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import zipfile
from pathlib import Path
VERSION_NAME = "0.2.3"
VERSION_CODE = 5
PACKAGE = "me.teafry.andtunes"
MIN_SDK = 26
TARGET_SDK = 33
BUILD_TOOLS = "34.0.0"
HERE = Path(__file__).resolve().parent
SRC = HERE / "app" / "src" / "main"
OUT = HERE / "build"
APK_NAME = "andTunes.apk"
SHIP_TO = HERE.parent / "lintunes" / "android" / APK_NAME
# Committed on purpose: both of trav's machines must sign identically, or
# `adb install -r` refuses an update built on the other one. A sideloaded app
# with no store listing has nothing for this key to protect.
KEYSTORE = HERE / "andtunes.keystore"
KEY_ALIAS = "andtunes"
KEY_PASS = "andtunes"
def _sdk() -> Path:
for env in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
if os.environ.get(env):
return Path(os.environ[env])
return Path.home() / "Android" / "Sdk"
def _java_home() -> Path:
# AGP-era tools want 21; Fedora's default `java` may be newer.
if os.environ.get("JAVA_HOME"):
return Path(os.environ["JAVA_HOME"])
return Path("/usr/lib/jvm/java-21-openjdk")
def run(*cmd):
printable = " ".join(str(c) for c in cmd)
print(f" $ {printable[:160]}{'…' if len(printable) > 160 else ''}")
subprocess.run([str(c) for c in cmd], check=True)
def build() -> Path:
sdk = _sdk()
bt = sdk / "build-tools" / BUILD_TOOLS
android_jar = sdk / "platforms" / f"android-{TARGET_SDK}" / "android.jar"
jbin = _java_home() / "bin"
for need in (bt / "aapt2", android_jar, jbin / "javac"):
if not need.exists():
sys.exit(f"missing {need} — see andtunes/README.md, Toolchain")
if OUT.exists():
shutil.rmtree(OUT)
gen, classes, dex = OUT / "gen", OUT / "classes", OUT / "dex"
for d in (gen, classes, dex):
d.mkdir(parents=True)
print("resources")
run(bt / "aapt2", "compile", "--dir", SRC / "res", "-o", OUT / "res.zip")
run(bt / "aapt2", "link", "-o", OUT / "base.apk",
"-I", android_jar, "--manifest", SRC / "AndroidManifest.xml",
"--java", gen, "--proguard", OUT / "aapt-rules.pro",
"--min-sdk-version", MIN_SDK, "--target-sdk-version", TARGET_SDK,
"--version-code", VERSION_CODE, "--version-name", VERSION_NAME,
"--auto-add-overlay", OUT / "res.zip")
print("java")
sources = sorted(str(p) for p in (SRC / "java").rglob("*.java"))
sources += sorted(str(p) for p in gen.rglob("*.java"))
run(jbin / "javac", "--release", "11", "-Xlint:-options", "-nowarn",
"-encoding", "UTF-8", "-cp", android_jar, "-d", classes, *sources)
classes_jar = OUT / "classes.jar"
with zipfile.ZipFile(classes_jar, "w") as jar:
for f in classes.rglob("*.class"):
jar.write(f, f.relative_to(classes).as_posix())
print("r8")
run(jbin / "java", "-cp", bt / "lib" / "d8.jar", "com.android.tools.r8.R8",
"--release", "--min-api", MIN_SDK, "--lib", android_jar,
"--pg-conf", OUT / "aapt-rules.pro", "--pg-conf", HERE / "proguard.pro",
"--output", dex, classes_jar)
print("package")
unsigned = OUT / "unsigned.apk"
shutil.copyfile(OUT / "base.apk", unsigned)
with zipfile.ZipFile(unsigned, "a", zipfile.ZIP_DEFLATED) as apk:
apk.write(dex / "classes.dex", "classes.dex")
aligned = OUT / "aligned.apk"
run(bt / "zipalign", "-f", "-p", "4", unsigned, aligned)
final = OUT / APK_NAME
env_java = dict(os.environ, JAVA_HOME=str(_java_home()),
PATH=f"{jbin}:{os.environ.get('PATH', '')}")
print(f" $ apksigner sign … {final.name}")
subprocess.run([str(bt / "apksigner"), "sign", "--ks", str(KEYSTORE),
"--ks-key-alias", KEY_ALIAS,
"--ks-pass", f"pass:{KEY_PASS}",
"--key-pass", f"pass:{KEY_PASS}",
"--out", str(final), str(aligned)],
check=True, env=env_java)
print(f"built {final} ({final.stat().st_size // 1024} KiB, "
f"v{VERSION_NAME} / {VERSION_CODE})")
return final
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("--install", action="store_true",
help="adb install -r the result and launch it")
ap.add_argument("--ship", action="store_true",
help="copy the APK into lintunes/android/ for LinTunes")
args = ap.parse_args()
apk = build()
if args.ship:
SHIP_TO.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(apk, SHIP_TO)
# LinTunes can't cheaply read a binary manifest, so the version the
# APK carries rides next to it as plain JSON (lintunes/andtunes/install.py).
SHIP_TO.with_suffix(".json").write_text(json.dumps(
{"version_name": VERSION_NAME, "version_code": VERSION_CODE}) + "\n")
print(f"shipped to {SHIP_TO}")
if args.install:
run("adb", "install", "-r", apk)
run("adb", "shell", "am", "start", "-n", f"{PACKAGE}/.MenuActivity")
if __name__ == "__main__":
main()