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
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.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()
|