andTunes phase 3. The R1 is now one more machine in the Round 38 play-count model, and nothing lands on it that Android can't play. Play counts: andTunes 0.2.0 counts a play on a natural finish (LinTunes' rule) and keeps Music/andTunes/plays/andtunes-<install id>.json in exactly the per-machine-totals journal shape, written beside the old one and renamed over it. Each sync first folds it into <data_dir>/plays/ with a new play_journal.merge_totals, the per-track max: the file now has two homes and both desktops may bring it back, so the max converges and an older copy can never pull a count down. Nothing is written when nothing moved, and PlayJournal.load needed no change. Format gate: the planner reuses export/web_support.conversion_for outright, since Android's MediaPlayer decodes the browser's set. FairPlay is refused and reported; ALAC, AIFF and oddities land as FLAC, converted once into a per-track cache and size-diffed after that. With no ffmpeg, the export's "sync without them?" question. A file ffmpeg can't read costs that song, not the sync. "Sync Playlist to Rabbit (Auxio)" is retired from the menu; device_sync's helpers stay because export and andTunes import them. The dev fixture grew a real ALAC track, a Protected AAC .m4p and an "Odd Formats" playlist. Verified on the R1 with it: AIFF and ALAC arrived as FLAC and played, the FairPlay track was refused, four plays came back on the next sync and each track's effective count rose by exactly one, and a third sync copied nothing. Found along the way: USB re-enumeration can wedge gvfsd-mtp, after which anything touching the mount (find_device, the GUI tests) hangs in an uninterruptible wait. CLAUDE.md now has the recovery. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ZCVBTJRFJ2XfMshu2gtUv
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.0"
|
|
VERSION_CODE = 2
|
|
|
|
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()
|