The sync was fine. Everything it put on the Rabbit was there and correct -- 2,432 media files, 208 covers, a manifest whose every field type-checks against the parser -- and andTunes still answered "No library / Couldn't read library.json". Fifteen tracks took all 2,439 songs down with them. Library.group() keyed albumsByKey case-folded and artistsByName raw-case, and only ever built the Artist inside `if (album == null)`. So the second track of an album whose artist is spelled differently found the album already there, skipped the block that would have made the artist, and dereferenced the null that came back. Six artists in trav's library are spelled two ways: RJD2/Rjd2, Toro Y Moi/Toro y Moi, FatBoy Slim/Fatboy Slim, LOVING/Loving, Land Of The Loops/Land of the Loops, Salami Rose Joe Louis/salami rose joe louis. It had worked until the 12th because that sync was the first to carry both spellings of one of those albums. Both maps fold case now, and the artist is fetched-or-made before the album block and held, so no lookup left in group() can come back null. Artist gains a key the way Album always had one, and ListActivity navigates by it -- ALBUM_ROW already passed album.key, so artists just stopped being the exception. The device filesystem is case-insensitive, so those two spellings are one folder there. plan_andtunes_sync compared exact strings and saw every such file as stale *and* missing, deleting and re-copying it over MTP on every sync forever; the diff and the collision rule both fold now. plan.stale still carries the device's own spelling, since that is what _delete_stale unlinks by. Verified on the Rabbit: 2,439 songs listed, RJD2 one row of 12 songs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
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.2.1"
|
|
VERSION_CODE = 3
|
|
|
|
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()
|