Podlite 1.12: a build for Android 4.1 (Xperia J) with ExoPlayer, speed and skip silences

- One build per Android level, as in Tenda and Plexamp Lite: build/Podlite.apk (Android 2.2+, versionCode 1308) and
  build/Podlite-api16.apk (Android 4.1+, 1316), from shared src/ plus platform/api8 or platform/api16; build.sh now
  runs on the local toolchain (aapt2, dx or R8, signapk.py; the JKS key converted for the run).
- Android 4.1 build: ExoPlayer 2.18.7 behind an Engine interface, with playback speed (player header) and skipping
  silences (Settings), a 3-minute buffer and MediaPlayer as fallback for audio it can't decode; a notification and
  lock screen with the cover and buttons; Holo; a menu button on phones without a Menu key; metered networks for
  "download on Wi-Fi only".
- Both builds: tlskit's Http.abort fix (a finished response's pooled connection is no longer closed under a later
  request); the lock screen's and newer headsets' play/pause keys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XoséandClaude Opus 5.5 committed 2026-11-01 21:56:18 +01:00
1 parent b2ca212c45
commit 666a7eb8cd
32 files changed
+1267 -123

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Checks the dex inside an APK the way Dalvik's dexopt does at install time: format version,
size, Adler-32, SHA-1, no post-035 sections. usage: check_dex.py app.apk"""
import hashlib
import struct
import sys
import zipfile
import zlib
d = zipfile.ZipFile(sys.argv[1]).read("classes.dex")
problems = []
if d[:8] != b"dex\n035\x00":
problems.append("dex version %r (Android 2.3 needs 035)" % d[:8])
if struct.unpack("<I", d[32:36])[0] != len(d):
problems.append("file_size field wrong")
if zlib.adler32(d[12:]) & 0xFFFFFFFF != struct.unpack("<I", d[8:12])[0]:
problems.append("Adler-32 checksum wrong")
if hashlib.sha1(d[32:]).digest() != d[12:32]:
problems.append("SHA-1 signature wrong")
map_off = struct.unpack("<I", d[52:56])[0]
for i in range(struct.unpack("<I", d[map_off:map_off + 4])[0]):
t = struct.unpack("<H", d[map_off + 4 + i * 12:map_off + 6 + i * 12])[0]
if t in (7, 8):
problems.append("contains call_site/method_handle sections (needs Android 8+)")
for p in problems:
print("DEX PROBLEM:", p)
print("classes.dex: %d bytes, %s" % (len(d), "OK" if not problems else "REJECTED"))
sys.exit(1 if problems else 0)
+35
View File
@@ -0,0 +1,35 @@
#!/bin/bash
# Fetches what the Android 4.1 build needs beyond tools/setup_toolchain.sh: ExoPlayer 2.18.7 (Google's Maven) and
# Guava (Maven Central), checked against these SHA-256s, into $TOOLS/exoplayer-2.18.7 (TOOLS default ~/android-toolchain).
# 2.18.7 because it's the newest that needs only Guava (2.19 adds AndroidX Core); every 2.x runs on Android 4.1.
set -euo pipefail
T=${TOOLS:-$HOME/android-toolchain}
D=$T/exoplayer-2.18.7
G=https://dl.google.com/dl/android/maven2/com/google/android/exoplayer
M=https://repo1.maven.org/maven2/com/google/guava
mkdir -p "$D"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
get() { # url sha256 file
if [ -f "$D/$3" ] && [ "$(shasum -a 256 "$D/$3" | cut -d' ' -f1)" = "$2" ]; then return; fi
curl -fsSL -o "$tmp/$3" "$1"
[ "$(shasum -a 256 "$tmp/$3" | cut -d' ' -f1)" = "$2" ] || { echo "$3: checksum mismatch"; exit 1; }
mv "$tmp/$3" "$D/$3"
}
for m in core:2b51c5f07aa25f89e4adb7a833073e1a4adc5b87a388bd0045ebc3429b1bf42f \
common:adfef7d3cbd69dd8fcc4ce05642a886c9fbc2764826bc39a6075ba6a0c62d003 \
datasource:cbe67ad9eaf94be7b35d1a625252a79ed5c18601d19f7b4d787acfbcea009208 \
decoder:598c0b444db71770f9b89b4e444e34cb03d5ed2eb5c12fb062afb2f9b05b9a1e \
extractor:c3e34c7b07ecd3c78a75bb7185671030bea4371ac88d854000a856fc05f7eeff \
database:31f0929246fb81296345be8414e7fa080a82deb8d382eb5d875974649030619d; do
a=${m%%:*}; sum=${m#*:}
get "$G/exoplayer-$a/2.18.7/exoplayer-$a-2.18.7.aar" "$sum" "exoplayer-$a.aar"
# an AAR is a zip: the code is classes.jar, the keep rules for apps that shrink it are proguard.txt
unzip -o -q -d "$tmp/$a" "$D/exoplayer-$a.aar"
cp "$tmp/$a/classes.jar" "$D/exoplayer-$a.jar"
if [ -f "$tmp/$a/proguard.txt" ]; then cp "$tmp/$a/proguard.txt" "$D/exoplayer-$a.pro"; fi
rm "$D/exoplayer-$a.aar"
done
get "$M/guava/31.0.1-android/guava-31.0.1-android.jar" 9425a423a4cb9d9db0356300722d9bd8e634cf539f29d97bb84f457cccd16eb8 guava.jar
get "$M/failureaccess/1.0.1/failureaccess-1.0.1.jar" a171ee4c734dd2da837e4b16be9df4661afab72a41adaf31eb84dfdaf936ca26 failureaccess.jar
ls "$D"
+18
View File
@@ -0,0 +1,18 @@
# R8 for the Android 4.1 build (build.sh): shrinks ExoPlayer and Guava to what the app uses and turns their Java 8
# code into what Android 4.1 runs. The app (and its Bouncy Castle copy) stays whole and readable in crash reports.
-keep class com.miniabs.** { *; }
-keep class com.minamp.** { *; }
-dontobfuscate
# referenced only by annotations, or by code for newer Androids that ExoPlayer guards by version
# (build.sh gives R8 the Android 4.1 API, so anything newer is "missing")
-dontwarn android.**
-dontwarn androidx.annotation.**
-dontwarn org.checkerframework.**
-dontwarn com.google.errorprone.annotations.**
-dontwarn com.google.j2objc.annotations.**
-dontwarn javax.annotation.**
-dontwarn kotlin.**
-dontwarn org.codehaus.mojo.animal_sniffer.**
-dontwarn sun.misc.Unsafe
-dontwarn java.lang.ClassValue
-dontwarn javax.lang.model.element.Modifier
+46
View File
@@ -0,0 +1,46 @@
# Find invokeinterface calls to java.lang.Object methods (toString/equals/hashCode/getClass) through an
# interface that doesn't declare them itself: Dalvik before 4.0 can't resolve those (NoSuchMethodError).
import os, re, subprocess, sys, zipfile
classes = sys.argv[1]; android_jar = sys.argv[2]
OBJ = {"toString:()Ljava/lang/String;", "equals:(Ljava/lang/Object;)Z", "hashCode:()I", "getClass:()Ljava/lang/Class;"}
z = zipfile.ZipFile(android_jar)
declared = {}
def declares(iface, m):
key = (iface, m)
if key in declared: return declared[key]
path = os.path.join(classes, iface + ".class")
src = path if os.path.exists(path) else None
args = ["javap", "-p", "-s", "-cp", classes if src else android_jar, iface.replace("/", ".")]
out = subprocess.run(args, capture_output=True, text=True).stdout
name, desc = m.split(":")
found = False
lines = out.splitlines()
for i, l in enumerate(lines):
if re.search(r"\b" + name + r"\(", l) and i + 1 < len(lines) and desc in lines[i + 1]:
found = True
# superinterfaces declaring it also count
if not found:
mm = re.search(r"interface \S+ extends (.+?) \{", out)
if mm:
for sup in mm.group(1).split(","):
sup = sup.strip().split("<")[0].replace(".", "/")
if sup and declares(sup, m): found = True
declared[key] = found
return found
hits = {}
files = [os.path.join(dp, f) for dp, _, fs in os.walk(classes) for f in fs if f.endswith(".class")]
for i in range(0, len(files), 200):
out = subprocess.run(["javap", "-c", "-p"] + files[i:i+200], capture_output=True, text=True).stdout
cur = None
for line in out.splitlines():
m = re.match(r"^(?:public |final |abstract |class |interface )*.*?(?:class|interface) (\S+)", line)
if m and not line.startswith(" "): cur = m.group(1)
mi = re.search(r"invokeinterface\s+#\d+,\s*\d+\s*// InterfaceMethod (\S+)\.(\w+):(\S+)", line)
if mi:
iface, name, desc = mi.group(1), mi.group(2), mi.group(3)
sig = name + ":" + desc
if sig in OBJ and not declares(iface, sig):
hits.setdefault((iface, sig), set()).add(cur)
for (iface, sig), users in sorted(hits.items()):
print(f"{iface}.{sig} <- {len(users)} class(es): " + ", ".join(sorted(users))[:300])
print("total patterns:", len(hits))
+146
View File
@@ -0,0 +1,146 @@
"""v1 (JAR) APK signer in the format Android's own SignApk used in the 2.x era, plus zipalign.
Why not jarsigner: modern JDKs write "SHA-1-Digest" entries (Android <= 4.2 only looks for
"SHA1-Digest"/"SHA-Digest") and put sha1WithRSAEncryption in the PKCS#7 SignerInfo, which old
JarUtils can't map. Either makes Android 2.x treat the APK as unsigned and refuse to install it.
Usage: signapk.py <keystore.p12> <password> <in.apk> <out.apk>
"""
import base64
import hashlib
import struct
import sys
import zipfile
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.serialization import pkcs12
ALIGN = 4
FIXED_TIME = (2008, 1, 1, 0, 0, 0)
# ---------------------------------------------------------------- minimal DER
def der_len(n):
if n < 0x80:
return bytes([n])
b = n.to_bytes((n.bit_length() + 7) // 8, "big")
return bytes([0x80 | len(b)]) + b
def tlv(tag, body):
return bytes([tag]) + der_len(len(body)) + body
def seq(*parts):
return tlv(0x30, b"".join(parts))
def set_of(*parts):
return tlv(0x31, b"".join(sorted(parts)))
def oid(dotted):
nums = [int(x) for x in dotted.split(".")]
body = bytes([nums[0] * 40 + nums[1]])
for n in nums[2:]:
chunk = [n & 0x7F]
n >>= 7
while n:
chunk.append(0x80 | (n & 0x7F))
n >>= 7
body += bytes(reversed(chunk))
return tlv(0x06, body)
def integer(n):
b = n.to_bytes((n.bit_length() + 8) // 8 or 1, "big", signed=True)
return tlv(0x02, b)
NULL = b"\x05\x00"
OID_SHA1 = "1.3.14.3.2.26"
OID_RSA = "1.2.840.113549.1.1.1"
OID_DATA = "1.2.840.113549.1.7.1"
OID_SIGNED_DATA = "1.2.840.113549.1.7.2"
def pkcs7_detached(sf_bytes, key, cert):
signature = key.sign(sf_bytes, padding.PKCS1v15(), hashes.SHA1())
cert_der = cert.public_bytes(serialization.Encoding.DER)
sha1_alg = seq(oid(OID_SHA1), NULL)
signer_info = seq(
integer(1),
seq(cert.issuer.public_bytes(), integer(cert.serial_number)),
sha1_alg,
seq(oid(OID_RSA), NULL), # rsaEncryption, as old JarUtils expects
tlv(0x04, signature),
)
signed_data = seq(
integer(1),
set_of(sha1_alg),
seq(oid(OID_DATA)), # detached: content is META-INF/CERT.SF
tlv(0xA0, cert_der), # [0] IMPLICIT certificates
set_of(signer_info),
)
return seq(oid(OID_SIGNED_DATA), tlv(0xA0, signed_data))
# ---------------------------------------------------------------- manifest text
def b64sha1(data):
return base64.b64encode(hashlib.sha1(data).digest()).decode()
def wrap(line):
"""JAR manifest lines are at most 72 bytes; continuation lines start with a space."""
raw = line.encode("utf-8")
out = [raw[:70]]
raw = raw[70:]
while raw:
out.append(b" " + raw[:69])
raw = raw[69:]
return b"\r\n".join(out) + b"\r\n"
def sign(p12_path, password, src, dst):
key, cert, _ = pkcs12.load_key_and_certificates(open(p12_path, "rb").read(), password.encode())
zin = zipfile.ZipFile(src)
entries = [i for i in zin.infolist()
if not i.filename.startswith("META-INF/") and not i.is_dir()]
data = {i.filename: zin.read(i.filename) for i in entries}
manifest = b"Manifest-Version: 1.0\r\nCreated-By: 1.0 (Android SignApk)\r\n\r\n"
sf_entries = b""
for i in entries:
section = wrap("Name: " + i.filename) + wrap("SHA1-Digest: " + b64sha1(data[i.filename])) + b"\r\n"
manifest += section
sf_entries += wrap("Name: " + i.filename) + wrap("SHA1-Digest: " + b64sha1(section)) + b"\r\n"
sf = (b"Signature-Version: 1.0\r\nCreated-By: 1.0 (Android SignApk)\r\n"
+ wrap("SHA1-Digest-Manifest: " + b64sha1(manifest)) + b"\r\n" + sf_entries)
rsa = pkcs7_detached(sf, key, cert)
with zipfile.ZipFile(dst, "w") as zout:
def put(name, payload, compress):
zi = zipfile.ZipInfo(name, date_time=FIXED_TIME)
zi.compress_type = compress
zi.external_attr = 0o644 << 16
zi.create_system = 0
extra = b""
if compress == zipfile.ZIP_STORED:
base = zout.fp.tell() + 30 + len(name.encode("utf-8")) + 6
pad = (ALIGN - base % ALIGN) % ALIGN
extra = struct.pack("<HHH", 0xD935, 2 + pad, ALIGN) + b"\0" * pad
zi.extra = extra
zout.writestr(zi, payload)
put("META-INF/MANIFEST.MF", manifest, zipfile.ZIP_DEFLATED)
put("META-INF/CERT.SF", sf, zipfile.ZIP_DEFLATED)
put("META-INF/CERT.RSA", rsa, zipfile.ZIP_DEFLATED)
for i in entries:
put(i.filename, data[i.filename], i.compress_type)
if __name__ == "__main__":
sign(*sys.argv[1:5])