Files
lintunes/andtunes/build.py
T
travandClaude Opus 5 efa07b05c6 v0.20.4: the song that wasn't there
trav couldn't play the first track of his playlist. Shuffle off gave him song
two; shuffle on gave him a random one. The player was right both times:
library.json named a song whose file was not on the device, and skipping an
unplayable file advances one slot. Tapping any song that *is* there plays
exactly that song, shuffle or not.

The file was missing because a gvfs-MTP mount can hold a phantom directory --
one it lists happily while the device has no such folder. Every write into it
fails EIO, and mkdir(exist_ok=True) sees the phantom and does nothing, so it
never heals; only remounting clears it. That folder was new because Round 52's
retag moved the file under a new artist.

So a copy that raises OSError now costs that song, not the sync, and the song
is left out of the m3u and library.json. Aborting cost 2,400 songs for one
folder; counting it present anyway put a song in the manifest that isn't on
the device, which is the bug you could hear. The names come back in the
summary and a dialog rather than vanishing.

Planning is a worker now. plan_andtunes_sync is pure and writes nothing, but
it walks every file on the device, and over MTP that is thousands of round
trips -- on the GUI thread it froze the window and GNOME offered to kill
LinTunes, which is how a sync got force-quit halfway through.

And the sync stopped going quiet at the end. "Album art 501/501" is emitted
before the last album, and then _write_index, _ship_buttons and
_prune_empty_dirs ran silently: 501 exists() stats over MTP, ~840 KB of
writes and a full tree walk, with the progress line frozen. They report now,
and _write_index reads Art/ in one listing instead of a stat per album.

andTunes 0.2.2: the wheel scrolls the other way in lists, and smoothly --
a detent adds to a pixel debt that a Choreographer callback pays off a
fraction per frame, so one detent eases to a stop and a fast spin blends into
one movement instead of teleporting a row at a time. Volume is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
2026-09-14 13:25:15 -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.2"
VERSION_CODE = 4
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()