The Android half of andTunes had been paused on a 1 GB toolchain download (Gradle wrapper, Android Gradle Plugin, Kotlin). None of that was needed: platform 33 and build-tools 34 were already in ~/Android/Sdk, and an app that links no libraries needs five SDK steps, not a build system. andtunes/build.py runs aapt2 -> javac -> R8 -> zipalign -> apksigner and produces a 61 KiB APK. andTunes 0.1.0 is framework-only Java: a six-tile menu that loads nothing, a streaming library.json parse grouped into artists and albums in one pass, one ListActivity for every list (songs and artist songs open with Shuffle all, albums carry art thumbs, search as you type), a Now playing bar under every list, and a white Now Playing screen with square art, a slim scrubber, prev/play/next, shuffle and repeat, and no back button. A foreground PlaybackService owns the queue: audio focus, becoming-noisy, MediaSession and a MediaStyle notification, end of queue pauses, missing files skip, and the last queue and position come back on launch. Black on white, and every size doubled for the R1's override density. Cold start to the menu: 313-338 ms. The scroll wheel turned out to be DPAD_UP/DOWN (stock Generic.kl), not volume. The app takes both in dispatchKeyEvent: lists scroll a row per detent, and the menu and Now Playing get volume. LinTunes side: Connections > Install andTunes on Rabbit... installs the committed APK over adb and grants all-files access, or copies it to Download/ over MTP when there's no adb. Sync fills Buttons/ with default artwork only where a file is missing, so trav's own art is never overwritten. Verified against the dev library only, synced to the R1 and driven with adb input + screencap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSsYtRn4WZx6xj4GEdVPt9
156 lines
5.9 KiB
Python
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.1.0"
|
|
VERSION_CODE = 1
|
|
|
|
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()
|