The play journals keep running totals per track, which is enough for "most played ever" and nothing a Wrapped asks: top songs of this year, minutes listened, which month, what time of day, what got skipped. Those all come from one timestamped line per listen, so LinTunes now keeps that, while Preferences -> General -> "Keep a play history for my yearly Wrapped" is on. history/<machine>/<YYYY-MM>.jsonl, one writer per folder like the journals: local start time with its offset, ms actually heard (Player adds position steps under 1.5 s, so seeks and pauses don't count), how it ended, where it played and from what, and a name/artist/album snapshot that outlives a delete. Under 5 s is noise and never written. andTunes 0.3.0 writes the same lines on the Rabbit when sync says the desktop wants them, and sync copies each month home when the device's copy is longer. "Share this year's play history with friends" puts a trimmed copy (no ids, playlists or devices) in each friend's cassette folder, daily in November and December and monthly otherwise. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.3.0"
|
|
VERSION_CODE = 6
|
|
|
|
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()
|