12 Commits
Author SHA1 Message Date
XoséandClaude Opus 5.5 c771aa01bd Podlite 1.12: set speed and skipping silences before the audio opens; reopen on a skip toggle
ExoPlayer 2.18 sets up skipping silences when it opens the audio, so the engine now gets both settings before
prepare, and turning the option on or off while an episode plays reopens it at the same spot. Tested on the
Xperia J against test/mock_abs.py: 2x speed, silences skipped, the toggle mid-play, notification buttons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:33:51 +01:00
XoséandClaude Opus 5.5 666a7eb8cd 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>
2026-11-01 21:56:18 +01:00
XoséandClaude Opus 5.5 b2ca212c45 Add spec.md: goal, limits and rules for working on the project
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 09:05:36 +01:00
XoséandClaude Opus 5.5 4044303764 Merge branch 'internal-install' into main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:02:40 +02:00
XoséandClaude Opus 5.5 04ac5ae725 Podlite 1.11: install internally, keep loaded roots
Installed on the SD card, the app was killed whenever the card was shared over USB,
and a failed root read left HTTPS broken until a restart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:00:50 +02:00
xocko 084d7e9cad Merge pull request 'Podlite 1.10: bundled TLS 1.2, renamed from MiniABS, radio icon and amber' (#2) from bundled-tls into main 2026-10-19 17:11:36 +00:00
XoséandClaude Opus 5.5 db1175d25c Podlite 1.10: amber instead of purple, to match the radio icon
The purple came from the old Audiobookshelf icon. Accent and button gradient now take the radio's
brass and dial glow (#C27A1E, light #E8A64A, buttons #D98C2B to #A35F14), and the backgrounds,
lines and greys lose their purple tint for warm ones. The blue 'new episode' dot stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 19:05:22 +02:00
XoséandClaude Opus 5.5 c673a0ba5d Podlite 1.9: radio icon
New launcher icon (the Podcasts Radio pack, master in art/icon-master.png). The status-bar icon
was cut out of the old headphones artwork; make_icons.py now draws a white radio silhouette.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:57:02 +02:00
XoséandClaude Opus 5.5 042364b45c README: APK size, former name, JSON_JAR for the tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:40:21 +02:00
XoséandClaude Opus 5.5 bede72ed09 README: Newslite is now Noticias Lite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:20:12 +02:00
XoséandClaude Opus 5.5 a4be0bab44 Podlite 1.8: MiniABS is now Podlite
Renames the app (label, texts, Audiobookshelf client name, User-Agent, APK). The package name,
preferences and signing key stay, so it installs over MiniABS and keeps its settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:57:47 +02:00
xocko 10a2aa9ef5 Merge pull request 'Backport from the iOS version: never mark early endings as played, stall warning, Close' (#1) from ios-backports into main
Reviewed-on: xocko/miniabs#1
2026-10-17 14:26:41 +00:00
63 changed files with 1436 additions and 210 deletions

No files matched your search

+5 -3
View File
@@ -1,9 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- build.sh makes one APK per Android level from this file: it sets minSdkVersion and targetSdkVersion for each,
and versionCode = the one below x 100 + the level (13 -> 1308 for Android 2.2, 1316 for 4.1). -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.miniabs"
android:versionCode="8"
android:versionName="1.7"
android:installLocation="auto">
android:versionCode="13"
android:versionName="1.12"
android:installLocation="internalOnly">
<uses-sdk android:minSdkVersion="8" android:targetSdkVersion="10" />
+45 -19
View File
@@ -1,36 +1,37 @@
# MiniABS
# Podlite
A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones. Native Java, no libraries, ~130 KB. The interface is in Galician, with large text and touch targets sized for the Mini's 240×320 screen. Its purple colour scheme and launcher icon come from the Audiobookshelf icon pack (`art/icon_master_512.png`); `tools/make_icons.py` derives the notification and player icons from it.
A tiny Audiobookshelf **podcast** client for the Samsung Galaxy Mini (GT-S5570) and other Android 2.2+ phones, with a
second build for Android 4.1 and later (Xperia J, Galaxy S II…): see *Android 4.1 build*. Native Java; the APK is about 1.1 MB, nearly all of it the bundled TLS stack. Formerly MiniABS. The interface is in Galician, with large text and touch targets sized for the Mini's 240×320 screen. The launcher icon is the "Podcasts Radio" pack (master in `art/icon-master.png`); `tools/make_icons.py` draws the notification icon (a white radio silhouette) and the player icons. Its warm dark colours and amber accent come from the radio: brass and the glow of its dial.
## Install
1. Copy `MiniABS.apk` to the phone (USB to the SD card, or download it in the browser).
1. Copy `Podlite.apk` (Android 4.1+: `Podlite-api16.apk`; Tenda picks the right one by itself) to the phone (USB to the SD card, or download it in the browser).
2. On the phone: **Settings → Applications → Unknown sources** → tick it.
3. Open the APK from a file manager and install. It can be moved to the SD card (Settings → Applications → Manage applications → MiniABS → Move to SD card).
3. Open the APK from a file manager and install. Podlite installs to internal storage (about 1 MB): an app on the SD card is killed whenever the phone shares its SD card over USB, which stopped playback and broke HTTPS until a restart.
## Connecting
At home, the simplest option is your server's **local http:// address**, e.g. `http://192.168.1.10:13378`.
Sign in with your username and password, or tick "Use an API key instead". It works with both current servers (2.26+, short-lived access tokens that MiniABS refreshes automatically) and older ones (single long-lived token).
Sign in with your username and password, or tick "Use an API key instead". It works with both current servers (2.26+, short-lived access tokens that Podlite refreshes automatically) and older ones (single long-lived token).
## HTTPS (from outside your home)
Android 2.3 only speaks TLS 1.0, which modern servers refuse, so since 1.7 MiniABS brings its own
**TLS 1.2** (Bouncy Castle, the same client as Minamp, with Mozilla's root certificates,
Android 2.3 only speaks TLS 1.0, which modern servers refuse, so since 1.7 Podlite brings its own
**TLS 1.2** (Bouncy Castle, the same client as Plexamp Lite, with Mozilla's root certificates,
keep-alive and session resumption). Sign in with your server's normal `https://` address, at
home or away; nothing changes on the server. (A legacy TLS 1.0 port set up for older versions
keeps working too.)
### App: certificates
- **Built-in roots:** MiniABS checks certificates against Mozilla's current root list (`assets/roots`), so Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL and the rest just work.
- **Anything else** (self-signed, a private CA, a new root): MiniABS shows the certificate's SHA-256 fingerprint and asks once. Compare it with the fingerprint your computer's browser shows for the site before tapping Trust. It then trusts the CA that issued it, so renewals keep working.
- **Built-in roots:** Podlite checks certificates against Mozilla's current root list (`assets/roots`), so Let's Encrypt, Google Trust Services, Sectigo/ZeroSSL and the rest just work.
- **Anything else** (self-signed, a private CA, a new root): Podlite shows the certificate's SHA-256 fingerprint and asks once. Compare it with the fingerprint your computer's browser shows for the site before tapping Trust. It then trusts the CA that issued it, so renewals keep working.
- **Settings → Certificates you trusted** lets you forget them.
### App: streaming
Android 2.x's media player can't stream modern https:// URLs, so for HTTPS servers MiniABS runs a small proxy on the phone (127.0.0.1 only, random unguessable URLs). It forwards seeks and gets a fresh sign-in token for each request, so long episodes don't break when the token expires.
Android 2.x's media player can't stream modern https:// URLs, so for HTTPS servers Podlite runs a small proxy on the phone (127.0.0.1 only, random unguessable URLs). It forwards seeks and gets a fresh sign-in token for each request, so long episodes don't break when the token expires.
## Using it
@@ -38,32 +39,57 @@ Android 2.x's media player can't stream modern https:// URLs, so for HTTPS serve
- **Shows**: every podcast; tap one for its episodes. Menu key → sort oldest/newest, hide played.
- **Saved**: episodes downloaded to the SD card, plus download progress.
- **Tap** an episode to play. **Long-press** for Download, Delete download, Mark as played/unplayed, Go to show.
- **Player**: skip back/forward (set the amounts in Settings), seek bar, show notes. Menu key → sleep timer, mark as played, download, stop, close MiniABS.
- **Close**: Menu key → *Pechar MiniABS* (on the Latest/Shows/Saved screen or the player) saves the position, stops playback and closes the app.
- **Player**: skip back/forward (set the amounts in Settings), seek bar, show notes. Menu key → sleep timer, mark as played, download, stop, close Podlite.
- **Close**: Menu key → *Pechar Podlite* (on the Latest/Shows/Saved screen or the player) saves the position, stops playback and closes the app.
- **Headset buttons**: play/pause; next/previous skip forward/back. Playback pauses when headphones are unplugged and during calls.
Colored dots: blue = new, amber = in progress (with a bar), grey = played.
## Progress sync
Playback opens an Audiobookshelf session and syncs every 15 seconds and on pause, so progress follows you to other apps. An episode only counts as played when playback gets within a minute of its end: a stream that drops or a saved file cut short is retried or reported, never marked as played. If playback says it's playing but stops moving for 12 seconds, MiniABS says so. Saved episodes play offline; their positions are kept on the phone and sent to the server next time you're online (or tap refresh on the Saved tab).
Playback opens an Audiobookshelf session and syncs every 15 seconds and on pause, so progress follows you to other apps. An episode only counts as played when playback gets within a minute of its end: a stream that drops or a saved file cut short is retried or reported, never marked as played. If playback says it's playing but stops moving for 12 seconds, Podlite says so. Saved episodes play offline; their positions are kept on the phone and sent to the server next time you're online (or tap refresh on the Saved tab).
## Android 4.1 build
One build per **Android level**, never per phone (the same pattern as Tenda and Plexamp Lite): `build/Podlite.apk`
for Android 2.2+ and `build/Podlite-api16.apk` for 4.1+, from the same source, package and key. `versionCode` is
the manifest's × 100 + the level (13 → 1308 and 1316), so a phone that moves up a level gets the newer build as an
ordinary update. What only one level can do is in `platform/api8/…/Platform.java` and `platform/api16/…/Platform.java`
(the same methods; keep them in step), plus `platform/api16/res` (Holo). The Android 4.1 build adds:
- **ExoPlayer 2.18** (`platform/api16/…/ExoEngine.java`) behind the small `Engine` interface the player service
uses (`MediaPlayerEngine` on Android 2.x), with **playback speed** (the button at the top of the player: 0,8× to 2×)
and **skip silences** (Settings → Reprodución). It keeps up to 3 minutes buffered. An episode ExoPlayer can't
decode is retried with Android's own player.
- A **notification** with the cover and −15 ⏯ +30 ✕ (pull it down for a larger one); swiping it away while paused stops.
- **Lock-screen controls** with the cover (⏮ ⏭ skip back and forward).
- Android 4's **Holo** dialogs, menus and Settings; a ⋮ button on phones without a Menu key.
- "Download on Wi-Fi only" means any network that doesn't cost: a phone's Wi-Fi hotspot counts as mobile data.
## Limits
- Episodes are streamed with direct play. Formats Android 2.x can't decode (e.g. Opus) can't be played, since the transcoded HLS stream isn't supported on 2.x.
- No playback speed control: Android 2.x's media player doesn't support it.
- No playback speed control on Android 2.x (its media player doesn't support it); the Android 4.1 build has it.
- Downloads need an SD card. "Download on Wi-Fi only" is on by default.
## Building
Needs a JDK, `aapt`, `dalvik-exchange` (dx), `zipalign`, `apksigner` (Ubuntu: `apt install aapt dalvik-exchange zipalign apksigner`), an API 8 `android.jar`, and the renamed Bouncy Castle sources from Minamp's `tools/setup_toolchain.sh` (`BC_SRC`, default `~/android-toolchain/bcsrc`):
With the toolchain Plexamp Lite's `tools/setup_toolchain.sh` prepares (`~/android-toolchain`: aapt2, dx, the API 8
jar, Bouncy Castle renamed to `com.minamp.bc`, a Python venv with `cryptography`), plus for the Android 4.1 build the
API 16 jar, ExoPlayer (`tools/fetch_exoplayer.sh`), R8 (`d8.jar` from build-tools 34, in
`~/android-toolchain/calendar-tools/`) and a JDK 11+ (`build.sh` finds Homebrew's openjdk@17). No Gradle, no SDK:
ANDROID_JAR=/path/to/android-8/android.jar ./build.sh
./build.sh # -> build/Podlite.apk (Android 2.2+) and build/Podlite-api16.apk (4.1+)
./build.sh 16 # one level only
Compiling against the API 8 jar guarantees no newer Android APIs are used. The APK is signed with `miniabs.keystore` (password `miniabs`); keep it to install updates over the top.
Per level: aapt2 → javac (Java 8 bytecode against that level's `android.jar`) → for Android 2.x the Dalvik 2.x link
check and dx, for 4.1 R8 (`tools/r8-rules.pro`: keeps the app whole, shrinks ExoPlayer and Guava, one dex) →
`tools/signapk.py` (v1, SHA-1, the classic SignApk format Android ≤ 4.2 needs) → `tools/check_dex.py`. The APK is
signed with `miniabs.keystore` (JKS, password and alias `miniabs`; build.sh converts it to a PKCS#12 copy in `build/`
for the run); keep it to install updates over the top.
All sizes (text, rows, buttons, covers) are in `res/values/dimens.xml`; change them there and rebuild.
`src/com/miniabs/net/` is the bundled HTTP/TLS client shared with Minamp, FreshMini and GingerCal; `assets/roots/` holds Mozilla's roots (regenerate with Minamp's `tools/make_roots.py`).
`src/com/miniabs/net/` is the bundled HTTP/TLS client shared with Plexamp Lite, Noticias Lite and Calendar Lite; `assets/roots/` holds Mozilla's roots (regenerate with Plexamp Lite's `tools/make_roots.py`).
`test/run_tests.sh` runs the API, JSON, progress, HTTPS-trust and streaming-proxy code on the desktop JVM against a mock Audiobookshelf server (`test/mock_abs.py`), over plain HTTP and over TLS with good, expired, wrong-host, forged and self-signed certificates (`test/make_pki.sh`).
`JSON_JAR=/path/to/json.jar test/run_tests.sh` (any org.json jar) runs the API, JSON, progress, HTTPS-trust and streaming-proxy code on the desktop JVM against a mock Audiobookshelf server (`test/mock_abs.py`), over plain HTTP and over TLS with good, expired, wrong-host, forged and self-signed certificates (`test/make_pki.sh`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

+102 -36
View File
@@ -1,38 +1,104 @@
#!/bin/sh
# Builds MiniABS.apk for Android 2.2+ (API 8) using the plain SDK tools, no Gradle.
# ANDROID_JAR android.jar for API 8 (compiling against it guarantees no newer APIs sneak in)
# KEYSTORE signing keystore (created on first build if missing)
set -e
#!/bin/bash
# Builds Podlite without the Android SDK or Gradle, one APK per Android level (same package and key):
# build/Podlite.apk Android 2.2 and later (Galaxy Mini) src + platform/api8, against android-8
# build/Podlite-api16.apk Android 4.1 and later (Xperia J...) src + platform/api16 + ExoPlayer, against android-16
# Each: aapt2 (resources; api16 overlays platform/api16/res) -> javac (Java 8 bytecode) -> then
# api8: Dalvik 2.x link check -> dx (dex 035)
# api16: R8 (shrinks ExoPlayer and Guava, desugars Java 8, dex 035 for Android 4.1)
# -> signapk.py (v1 / SHA-1 in the classic SignApk format, plus zipalign; Android <= 4.2 rejects jarsigner's).
# versionCode = the manifest's x 100 + the level, so a phone that moves up to Android 4.1 gets the newer build as an update.
# Toolchain (TOOLS, default ~/android-toolchain) from Plexamp Lite's tools/setup_toolchain.sh, plus tools/fetch_exoplayer.sh,
# the android-16 jar and R8 (d8.jar from build-tools 34) for api16.
# ./build.sh both
# ./build.sh 16 one level
set -euo pipefail
cd "$(dirname "$0")"
ANDROID_JAR=${ANDROID_JAR:-/home/claude/sable/android-platforms/android-8/android.jar}
KEYSTORE=${KEYSTORE:-miniabs.keystore}
BC_SRC=${BC_SRC:-$HOME/android-toolchain/bcsrc} # Bouncy Castle renamed to com.minamp.bc (Minamp's tools/setup_toolchain.sh)
OUT=build
rm -rf "$OUT"
mkdir -p "$OUT/gen" "$OUT/classes"
echo "== resources"
aapt package -f -m -J "$OUT/gen" -M AndroidManifest.xml -S res -A assets -I "$ANDROID_JAR" \
-F "$OUT/MiniABS.unsigned.apk"
echo "== compile (Java 8 bytecode, API 8 class library)"
javac -nowarn -Xlint:-options -encoding UTF-8 -source 8 -target 8 \
-bootclasspath "$ANDROID_JAR" -classpath "$ANDROID_JAR" -sourcepath "src:$BC_SRC:$OUT/gen" \
-d "$OUT/classes" $(find src "$OUT/gen" -name '*.java')
echo "== dex"
dalvik-exchange --dex --min-sdk-version=8 --output="$OUT/classes.dex" "$OUT/classes"
(cd "$OUT" && aapt add MiniABS.unsigned.apk classes.dex > /dev/null)
echo "== align + sign (v1 JAR signature with SHA-1, which Android 2.x requires)"
if [ ! -f "$KEYSTORE" ]; then
keytool -genkeypair -keystore "$KEYSTORE" -storepass miniabs -keypass miniabs -alias miniabs \
-keyalg RSA -keysize 2048 -validity 10000 -dname "CN=MiniABS" > /dev/null 2>&1
T=${TOOLS:-$HOME/android-toolchain}
BC_SRC=$T/bcsrc # Bouncy Castle renamed to com.minamp.bc
EXO=$T/exoplayer-2.18.7
R8=${R8:-$T/calendar-tools/d8.jar} # build-tools' d8.jar holds R8 too
PY=${PY:-$T/venv/bin/python} # signapk.py needs the cryptography package
[ -x "$PY" ] || PY=python3
KS=${KEYSTORE:-miniabs.keystore} # JKS, password and alias "miniabs": keep it, updates only install over the same key
LEVELS=${*:-8 16}
# a JDK 11+ (R8 needs it): the macOS java stub answers "javac" but can't run it
if ! javac -version >/dev/null 2>&1 || javac -version 2>&1 | grep -q '1\.8'; then
for j in "${JAVA_HOME:-}" /opt/homebrew/opt/openjdk@17 /opt/homebrew/opt/openjdk $(ls -d "$HOME"/tools/jdk-17* 2>/dev/null); do
if [ -n "$j" ] && [ -x "$j/bin/javac" ]; then export JAVA_HOME=$j PATH=$j/bin:$PATH; break; fi
done
fi
zipalign -f 4 "$OUT/MiniABS.unsigned.apk" "$OUT/MiniABS.aligned.apk"
apksigner sign --ks "$KEYSTORE" --ks-pass pass:miniabs --key-pass pass:miniabs --ks-key-alias miniabs \
--min-sdk-version 8 --v1-signing-enabled true --v2-signing-enabled false --v3-signing-enabled false \
--out "$OUT/MiniABS.apk" "$OUT/MiniABS.aligned.apk"
apksigner verify --min-sdk-version 8 "$OUT/MiniABS.apk"
ls -l "$OUT/MiniABS.apk"
mkdir -p build
# signapk.py reads PKCS#12: convert the JKS key into build/ for this run (the original stays as it is)
P12=build/signing.p12
rm -f $P12
keytool -importkeystore -srckeystore "$KS" -srcstoretype JKS -srcstorepass ${KEYPASS:-miniabs} -srcalias miniabs \
-destkeystore $P12 -deststoretype PKCS12 -deststorepass ${KEYPASS:-miniabs} -destkeypass ${KEYPASS:-miniabs} \
-noprompt >/dev/null 2>&1
trap 'rm -f build/signing.p12' EXIT
CODE=$(sed -n 's/.*android:versionCode="\([0-9]*\)".*/\1/p' AndroidManifest.xml)
for API in $LEVELS; do
case $API in
8) TARGET=10; APK=build/Podlite.apk ;;
16) TARGET=16; APK=build/Podlite-api16.apk
[ -f "$EXO/exoplayer-core.jar" ] || { echo "no ExoPlayer in $EXO: run tools/fetch_exoplayer.sh"; exit 1; }
[ -f "$R8" ] || { echo "no R8 at $R8 (build-tools 34's lib/d8.jar): set R8="; exit 1; } ;;
*) echo "no such level: $API (8 or 16)"; exit 1 ;;
esac
ANDROID_JAR=$T/platforms/android-$API/android.jar
OUT=build/api$API
echo "== Android level $API -> $APK (versionCode $((CODE * 100 + API)))"
rm -rf $OUT && mkdir -p $OUT/res $OUT/gen $OUT/classes
# this level's manifest; Android 3.2+ also counts a rotation as a screen size change; Holo settings on 4.1
sed -e "s/android:minSdkVersion=\"[0-9]*\"/android:minSdkVersion=\"$API\"/" \
-e "s/android:targetSdkVersion=\"[0-9]*\"/android:targetSdkVersion=\"$TARGET\"/" \
$( [ $API -ge 13 ] && echo '-e s/keyboardHidden|orientation"/keyboardHidden|orientation|screenSize"/' ) \
$( [ $API -ge 14 ] && echo '-e s#android:theme="@android:style/Theme"#android:theme="@android:style/Theme.Holo"#' ) \
AndroidManifest.xml > $OUT/AndroidManifest.xml
$T/aapt2 compile --dir res -o $OUT/res/compiled.zip
OVERLAY=
if [ -d platform/api$API/res ]; then
$T/aapt2 compile --dir platform/api$API/res -o $OUT/res/overlay.zip
OVERLAY="-R $OUT/res/overlay.zip"
fi
$T/aapt2 link -o $OUT/base.apk -I $ANDROID_JAR --manifest $OUT/AndroidManifest.xml \
--java $OUT/gen -A assets --version-code $((CODE * 100 + API)) --replace-version \
--no-version-vectors $OUT/res/compiled.zip $OVERLAY
LIBS=
[ $API -ge 16 ] && LIBS=$(ls $EXO/*.jar | tr '\n' ':')
find src platform/api$API $OUT/gen -name '*.java' > $OUT/sources.txt
javac -nowarn -Xlint:-options -source 8 -target 8 -encoding UTF-8 -g:source,lines \
-bootclasspath $ANDROID_JAR -classpath "$LIBS" -sourcepath "src:platform/api$API:$BC_SRC:$OUT/gen" \
-d $OUT/classes @$OUT/sources.txt 2>&1 | grep -v -E '^Picked up|^Note:|^warning: Cannot find annotation|^ reason: class file for|^[0-9]+ warnings?$' || true
test -f $OUT/classes/com/miniabs/HomeActivity.class
if [ $API -lt 11 ]; then
echo "-- Dalvik 2.x link check"
python3 tools/scan_dalvik.py $OUT/classes $ANDROID_JAR > $OUT/dalvik-scan.txt
if ! grep -q "^total patterns: 0$" $OUT/dalvik-scan.txt; then
cat $OUT/dalvik-scan.txt; echo "Calls Android 2.x can't link (see above): fix before shipping"; exit 1
fi
java -Xmx2g -jar $T/dx.jar --dex --output=$OUT/classes.dex $OUT/classes 2>&1 | grep -v '^Picked up' || true
else
echo "-- R8"
(cd $OUT/classes && zip -q -r ../app.jar .)
PGCONF=$(for f in tools/r8-rules.pro $EXO/*.pro; do printf -- '--pg-conf %s ' "$f"; done)
java -Xmx2g -cp "$R8" com.android.tools.r8.R8 --release --min-api $API --lib $ANDROID_JAR \
--map-diagnostics info none --output $OUT $PGCONF $OUT/app.jar $EXO/*.jar 2>&1 \
| grep -v -E '^Picked up|META-INF' || true
test -f $OUT/classes.dex
# one dex only: Android 4.1 can't load a second without a multidex library
if [ -f $OUT/classes2.dex ]; then echo "R8 made more than one dex file (over 64K methods)"; exit 1; fi
fi
cp $OUT/base.apk $OUT/unsigned.apk
(cd $OUT && zip -q unsigned.apk classes.dex)
$PY tools/signapk.py $P12 ${KEYPASS:-miniabs} $OUT/unsigned.apk $APK
python3 tools/check_dex.py $APK
ls -l $APK
done
+134
View File
@@ -0,0 +1,134 @@
package com.miniabs;
import java.io.File;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.DefaultLoadControl;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Renderer;
import com.google.android.exoplayer2.RenderersFactory;
import com.google.android.exoplayer2.Tracks;
import com.google.android.exoplayer2.audio.AudioAttributes;
import com.google.android.exoplayer2.audio.AudioRendererEventListener;
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer;
import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory;
import com.google.android.exoplayer2.mediacodec.MediaCodecSelector;
import com.google.android.exoplayer2.metadata.MetadataOutput;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.text.TextOutput;
import com.google.android.exoplayer2.upstream.DefaultDataSource;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import com.google.android.exoplayer2.video.VideoRendererEventListener;
/**
* ExoPlayer 2.18 (Android 4.1+ build): audio only, decoded with Android's MediaCodec. Over MediaPlayer it brings
* playback speed and skipping silences (its own audio processing, so it works on Android 4.1), a buffer of minutes,
* and errors that say what failed. Audio focus, unplugging and the wake/Wi-Fi locks stay with PlayerService.
*/
final class ExoEngine implements Engine {
private final ExoPlayer player;
private Listener listener;
private boolean preparedSent;
private boolean failed;
ExoEngine(final Context c) {
RenderersFactory audioOnly = new RenderersFactory() {
public Renderer[] createRenderers(Handler h, VideoRendererEventListener v, AudioRendererEventListener a,
TextOutput t, MetadataOutput m) {
return new Renderer[] {new MediaCodecAudioRenderer(c, MediaCodecSelector.DEFAULT, h, a)};
}
};
DefaultHttpDataSource.Factory http = new DefaultHttpDataSource.Factory()
.setUserAgent("Podlite").setConnectTimeoutMs(15000).setReadTimeoutMs(30000)
.setAllowCrossProtocolRedirects(true);
ProgressiveMediaSource.Factory sources = new ProgressiveMediaSource.Factory(new DefaultDataSource.Factory(c, http),
new DefaultExtractorsFactory().setConstantBitrateSeekingEnabled(true));
player = new ExoPlayer.Builder(c, audioOnly, sources)
// up to 3 minutes ahead in memory: rides out Wi-Fi gaps that MediaPlayer's seconds of buffer couldn't
.setLoadControl(new DefaultLoadControl.Builder()
.setBufferDurationsMs(60000, 180000, 1500, 3000)
.setPrioritizeTimeOverSizeThresholds(true)
.build())
.setAudioAttributes(new AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build(), false)
.setHandleAudioBecomingNoisy(false)
.setWakeMode(C.WAKE_MODE_LOCAL)
.setLooper(Looper.getMainLooper())
.build();
player.addListener(new Player.Listener() {
public void onPlaybackStateChanged(int state) {
if (listener == null || failed) return;
if (state == Player.STATE_READY && !preparedSent) {
preparedSent = true;
listener.onPrepared(ExoEngine.this);
} else if (state == Player.STATE_ENDED) {
listener.onCompletion(ExoEngine.this);
}
}
public void onPlayerError(PlaybackException e) {
if (listener == null || failed) return;
failed = true;
int code = e.errorCode;
// 4xxx: decoding; 3003: a container this player can't read. Android's MediaPlayer may manage.
boolean decoder = (code >= 4000 && code < 5000) || code == PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED;
Throwable cause = e.getCause();
String why = cause != null && cause.getMessage() != null ? cause.getMessage() : e.getMessage();
listener.onError(ExoEngine.this, "Erro de reprodución: " + e.getErrorCodeName() + (why != null ? " (" + why + ")" : ""), decoder);
}
public void onTracksChanged(Tracks tracks) {
// ExoPlayer plays silence through audio it can't decode instead of failing: fail, so MediaPlayer can try
if (listener == null || failed || tracks.isEmpty() || tracks.isTypeSupported(C.TRACK_TYPE_AUDIO, true)) return;
failed = true;
listener.onError(ExoEngine.this, "Este teléfono non pode descodificar este audio.", true);
}
});
}
public void setListener(Listener l) { listener = l; }
public void setSource(File file, String url) {
player.setMediaItem(MediaItem.fromUri(file != null ? Uri.fromFile(file) : Uri.parse(url)));
}
public void prepare() { player.prepare(); }
public void start() { player.setPlayWhenReady(true); }
public void pause() { player.setPlayWhenReady(false); }
public void seekTo(int ms) { player.seekTo(ms); }
public int position() { return (int) player.getCurrentPosition(); }
public int duration() {
long d = player.getDuration();
return d == C.TIME_UNSET || d < 0 ? 0 : (int) d;
}
public int bufferedPercent() { return player.getBufferedPercentage(); }
public void setVolume(float v) { player.setVolume(v); }
/** Before prepare for skipping silences to work: ExoPlayer 2.18 switches it on only when it sets up the audio. */
public void setSpeed(float speed, boolean skipSilence) {
player.setPlaybackParameters(new PlaybackParameters(speed));
player.setSkipSilenceEnabled(skipSilence);
}
public void release() {
listener = null;
player.release();
}
}
+157
View File
@@ -0,0 +1,157 @@
package com.miniabs;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.media.MediaMetadataRetriever;
import android.media.RemoteControlClient;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.os.StrictMode;
import android.view.View;
import android.view.ViewConfiguration;
import android.widget.RemoteViews;
/**
* What differs between Podlite's builds, for Android 4.1 and later (Xperia J, Galaxy S II...): ExoPlayer (speed,
* skipping silences), a notification with the cover and buttons, lock-screen controls, metered networks, and the
* ⋮ button on phones without a Menu key. platform/api8 has the same methods for Android 2.2; keep the two in step.
*/
final class Platform {
private Platform() {}
static final int API = 16;
static final boolean SPEED = true;
static final int COVER_PX = 320; // the lock screen shows it large
private static boolean initDone;
static void init(Context c) {
if (initDone) return;
initDone = true;
// Shared code sometimes closes a connection on the main thread (always brief). Android 2.x allows that;
// Android 3+ kills apps that target it. Allow it here too.
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder(StrictMode.getThreadPolicy()).permitNetwork().build());
}
static Engine newEngine(Context c, boolean fallback) {
return fallback ? new MediaPlayerEngine(c) : new ExoEngine(c);
}
/** Android 4.1 knows which networks cost: mobile data, and a Wi-Fi that is a phone's hotspot. */
static boolean unmetered(Context c) {
try {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
return ni != null && ni.isConnected() && !cm.isActiveNetworkMetered();
} catch (Exception e) {
return false;
}
}
static boolean needsMenuButton(Context c) {
return !ViewConfiguration.get(c).hasPermanentMenuKey();
}
/** Cover, title and show with −15 ⏯ +30 and ✕ (pulled down, a larger one). Swiping it away while paused stops. */
static Notification playerNotification(Context c, Episode ep, String what, Bitmap cover, int state, boolean ongoing) {
Notification n = new Notification.Builder(c)
.setSmallIcon(R.drawable.ic_stat)
.setContent(views(c, R.layout.notif_player, ep, what, cover, state))
.setContentIntent(openPlayer(c))
.setDeleteIntent(service(c, PlayerService.A_STOP, 14))
.setOngoing(ongoing)
.setWhen(0)
.setPriority(state == PlayerService.PLAYING ? Notification.PRIORITY_HIGH : Notification.PRIORITY_DEFAULT)
.build();
n.bigContentView = views(c, R.layout.notif_player_big, ep, what, cover, state);
return n;
}
private static Bitmap small, smallOf;
private static RemoteViews views(Context c, int layout, Episode ep, String what, Bitmap cover, int state) {
RemoteViews v = new RemoteViews(c.getPackageName(), layout);
v.setTextViewText(R.id.n_title, ep.title);
v.setTextViewText(R.id.n_sub, Util.join(what, ep.show));
if (cover != null) {
// the size the notification shows it at: a full-size bitmap would be copied to the status bar each time
if (cover != smallOf) {
int px = (int) (112 * c.getResources().getDisplayMetrics().density);
smallOf = cover;
small = cover.getWidth() <= px ? cover : Bitmap.createScaledBitmap(cover, px, px * cover.getHeight() / cover.getWidth(), true);
}
v.setImageViewBitmap(R.id.n_cover, small);
}
Prefs p = Prefs.get(c);
v.setTextViewText(R.id.n_back, "−" + p.skipBack());
v.setTextViewText(R.id.n_fwd, "+" + p.skipFwd());
boolean playing = state == PlayerService.PLAYING || state == PlayerService.LOADING;
v.setImageViewResource(R.id.n_play, playing ? R.drawable.ic_pause_small : R.drawable.ic_play_small);
v.setOnClickPendingIntent(R.id.n_back, service(c, PlayerService.A_BACK, 11));
v.setOnClickPendingIntent(R.id.n_play, service(c, PlayerService.A_TOGGLE, 12));
v.setOnClickPendingIntent(R.id.n_fwd, service(c, PlayerService.A_FWD, 13));
v.setOnClickPendingIntent(R.id.n_close, service(c, PlayerService.A_STOP, 14));
v.setOnClickPendingIntent(R.id.n_cover, openPlayer(c));
v.setOnClickPendingIntent(R.id.n_text, openPlayer(c));
v.setViewVisibility(R.id.n_close, View.VISIBLE);
return v;
}
static PendingIntent openPlayer(Context c) {
Intent i = new Intent(c, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
}
private static PendingIntent service(Context c, String action, int request) {
return PendingIntent.getService(c, request, new Intent(c, PlayerService.class).setAction(action), 0);
}
private static RemoteControlClient remote;
private static String remoteFor;
private static Bitmap remoteCover;
/** The lock screen's player: cover, title, show and buttons (⏮ ⏭ skip back and forward: media keys). */
static void lockScreen(Context c, Episode ep, Bitmap cover, int state) {
AudioManager am = (AudioManager) c.getSystemService(Context.AUDIO_SERVICE);
if (ep == null || state == PlayerService.IDLE) {
if (remote != null) {
remote.setPlaybackState(RemoteControlClient.PLAYSTATE_STOPPED);
am.unregisterRemoteControlClient(remote);
remote = null;
remoteFor = null;
remoteCover = null;
}
return;
}
if (remote == null) {
ComponentName buttons = new ComponentName(c, MediaButtonReceiver.class);
am.registerMediaButtonEventReceiver(buttons);
Intent i = new Intent(Intent.ACTION_MEDIA_BUTTON).setComponent(buttons);
remote = new RemoteControlClient(PendingIntent.getBroadcast(c.getApplicationContext(), 0, i, 0));
remote.setTransportControlFlags(RemoteControlClient.FLAG_KEY_MEDIA_PLAY | RemoteControlClient.FLAG_KEY_MEDIA_PAUSE
| RemoteControlClient.FLAG_KEY_MEDIA_PLAY_PAUSE | RemoteControlClient.FLAG_KEY_MEDIA_NEXT
| RemoteControlClient.FLAG_KEY_MEDIA_PREVIOUS);
am.registerRemoteControlClient(remote);
}
remote.setPlaybackState(state == PlayerService.PLAYING ? RemoteControlClient.PLAYSTATE_PLAYING
: state == PlayerService.LOADING ? RemoteControlClient.PLAYSTATE_BUFFERING
: state == PlayerService.ERROR ? RemoteControlClient.PLAYSTATE_ERROR : RemoteControlClient.PLAYSTATE_PAUSED);
if (ep.id.equals(remoteFor) && cover == remoteCover) return;
remoteFor = ep.id;
remoteCover = cover;
RemoteControlClient.MetadataEditor ed = remote.editMetadata(true);
ed.putString(MediaMetadataRetriever.METADATA_KEY_TITLE, ep.title);
ed.putString(MediaMetadataRetriever.METADATA_KEY_ARTIST, ep.show);
ed.putLong(MediaMetadataRetriever.METADATA_KEY_DURATION, (long) (ep.duration * 1000));
// a copy: Android recycles the artwork it's given when the next one comes, and this one is in the cover cache
if (cover != null) ed.putBitmap(RemoteControlClient.MetadataEditor.BITMAP_KEY_ARTWORK, cover.copy(Bitmap.Config.ARGB_8888, false));
ed.apply();
}
}
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Android 4.1 build: Holo (dark, like the app), without the action bar: the screens draw their own header. -->
<resources>
<style name="BaseTheme" parent="@android:style/Theme.Holo.NoActionBar" />
</resources>
+63
View File
@@ -0,0 +1,63 @@
package com.miniabs;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
/**
* What differs between Podlite's builds, for Android 2.2 and later (the Galaxy Mini). platform/api16 has the same
* methods for Android 4.1 and later; the rest of the app is shared and calls these. Keep the two in step.
*/
final class Platform {
private Platform() {}
/** The Android level this build is for: its minSdkVersion. */
static final int API = 8;
/** The player can change speed and skip silences (Android 2.x's can't). */
static final boolean SPEED = false;
/** Size the cover is loaded at for the notification and lock screen. */
static final int COVER_PX = 64;
/** At the start of each screen and service (main thread). */
static void init(Context c) {}
/** The player for an episode. {@code fallback}: the episode failed to decode in the usual one. */
static Engine newEngine(Context c, boolean fallback) {
return new MediaPlayerEngine(c);
}
/** On a network that doesn't cost by the megabyte (for "download only on Wi-Fi"): here, Wi-Fi. */
static boolean unmetered(Context c) {
ConnectivityManager cm = (ConnectivityManager) c.getSystemService(Context.CONNECTIVITY_SERVICE);
NetworkInfo ni = cm == null ? null : cm.getActiveNetworkInfo();
return ni != null && ni.isConnected() && ni.getType() == ConnectivityManager.TYPE_WIFI;
}
/** The phone has no Menu key, so screens need a ⋮ button. Android 2.x phones all have one. */
static boolean needsMenuButton(Context c) {
return false;
}
/** The playback notification. Android 2.x: title and state; its notifications can't have buttons. */
@SuppressWarnings("deprecation")
static Notification playerNotification(Context c, Episode ep, String what, Bitmap cover, int state, boolean ongoing) {
Notification n = new Notification(R.drawable.ic_stat, null, 0);
n.flags |= ongoing ? Notification.FLAG_ONGOING_EVENT : 0;
n.setLatestEventInfo(c, ep.title, Util.join(what, ep.show), openPlayer(c));
return n;
}
static PendingIntent openPlayer(Context c) {
Intent i = new Intent(c, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
return PendingIntent.getActivity(c, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
}
/** The lock screen's player (null episode or IDLE: take it away). Android 2.x has none. */
static void lockScreen(Context c, Episode ep, Bitmap cover, int state) {}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 B

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 977 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 679 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 499 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 B

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 806 B

After

Width:  |  Height:  |  Size: 863 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 B

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape><gradient android:startColor="#AA5CFF" android:endColor="#7022E6" android:angle="270" /><corners android:radius="6dp" /></shape>
<shape><gradient android:startColor="#E39A38" android:endColor="#A9621A" android:angle="270" /><corners android:radius="6dp" /></shape>
</item>
<item android:state_enabled="false">
<shape><solid android:color="@color/dim" /><corners android:radius="6dp" /></shape>
+1 -1
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape><gradient android:startColor="#AA5CFF" android:endColor="#7022E6" android:angle="270" /><corners android:radius="23dp" /></shape>
<shape><gradient android:startColor="#E39A38" android:endColor="#A9621A" android:angle="270" /><corners android:radius="23dp" /></shape>
</item>
<item>
<shape><gradient android:startColor="@color/accent_top" android:endColor="@color/accent_bottom" android:angle="270" /><corners android:radius="23dp" /></shape>
+8 -1
View File
@@ -9,7 +9,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MiniABS"
android:text="Podlite"
android:textSize="@dimen/header_text"
android:textStyle="bold"
android:textColor="@color/accent_light" />
@@ -33,6 +33,13 @@
android:layout_height="fill_parent"
android:background="@drawable/row_sel"
android:src="@drawable/ic_refresh" />
<ImageButton
android:id="@+id/menu"
android:layout_width="@dimen/header_h"
android:layout_height="fill_parent"
android:background="@drawable/row_sel"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
+2 -2
View File
@@ -31,7 +31,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="MiniABS"
android:text="Podlite"
android:textSize="@dimen/login_title"
android:textStyle="bold"
android:textColor="@color/accent_light" />
@@ -99,6 +99,6 @@
android:textSize="@dimen/login_tip"
android:textColor="@color/dim"
android:lineSpacingExtra="2dp"
android:text="Usa o enderezo https:// normal do servidor, na casa ou fóra: MiniABS trae o seu propio TLS moderno. Na casa tamén vale o enderezo local http://. Se o certificado non se recoñece, amosaráseche a súa pegada dixital para que a comprobes." />
android:text="Usa o enderezo https:// normal do servidor, na casa ou fóra: Podlite trae o seu propio TLS moderno. Na casa tamén vale o enderezo local http://. Se o certificado non se recoñece, amosaráseche a súa pegada dixital para que a comprobes." />
</LinearLayout>
</ScrollView>
+21
View File
@@ -18,6 +18,27 @@
android:singleLine="true"
android:ellipsize="end"
android:background="@drawable/row_sel" />
<!-- Android 4.1 build: playback speed -->
<Button
android:id="@+id/speed"
android:layout_width="wrap_content"
android:layout_height="fill_parent"
android:minWidth="@dimen/header_h"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:background="@drawable/row_sel"
android:textColor="@color/accent_light"
android:textSize="@dimen/header_sub"
android:textStyle="bold"
android:visibility="gone" />
<ImageButton
android:id="@+id/menu"
android:layout_width="@dimen/header_h"
android:layout_height="fill_parent"
android:background="@drawable/row_sel"
android:visibility="gone" />
</LinearLayout>
<LinearLayout
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Android 4.1 build's playback notification (Platform in platform/api16 fills it). RemoteViews-only views. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="@color/surface"
android:padding="4dp">
<ImageView android:id="@+id/n_cover"
android:layout_width="56dp" android:layout_height="56dp"
android:scaleType="centerCrop" android:src="@drawable/ic_launcher" />
<LinearLayout android:id="@+id/n_text"
android:layout_width="0dp" android:layout_height="fill_parent" android:layout_weight="1"
android:orientation="vertical" android:gravity="center_vertical"
android:paddingLeft="8dp" android:paddingRight="2dp">
<TextView android:id="@+id/n_title"
android:layout_width="fill_parent" android:layout_height="wrap_content"
android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold"
android:singleLine="true" android:ellipsize="end" />
<TextView android:id="@+id/n_sub"
android:layout_width="fill_parent" android:layout_height="wrap_content"
android:textColor="@color/sub" android:textSize="13sp"
android:singleLine="true" android:ellipsize="end" />
</LinearLayout>
<Button android:id="@+id/n_back" android:layout_width="42dp" android:layout_height="48dp"
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="13sp" android:textStyle="bold" />
<ImageButton android:id="@+id/n_play" android:layout_width="44dp" android:layout_height="48dp"
android:background="@drawable/btn_flat" android:scaleType="center" android:src="@drawable/ic_play_small" />
<Button android:id="@+id/n_fwd" android:layout_width="42dp" android:layout_height="48dp"
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="13sp" android:textStyle="bold" />
<ImageButton android:id="@+id/n_close" android:layout_width="34dp" android:layout_height="48dp"
android:background="@drawable/btn_flat" android:scaleType="center"
android:src="@android:drawable/ic_menu_close_clear_cancel" />
</LinearLayout>
+46
View File
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Android 4.1 build's notification, pulled down. RemoteViews-only views. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:orientation="horizontal"
android:background="@color/surface"
android:padding="6dp">
<ImageView android:id="@+id/n_cover"
android:layout_width="112dp" android:layout_height="112dp"
android:layout_gravity="center_vertical"
android:scaleType="centerCrop" android:src="@drawable/ic_launcher" />
<LinearLayout
android:layout_width="0dp" android:layout_height="fill_parent" android:layout_weight="1"
android:orientation="vertical" android:paddingLeft="10dp">
<LinearLayout android:layout_width="fill_parent" android:layout_height="0dp" android:layout_weight="1"
android:orientation="horizontal">
<LinearLayout android:id="@+id/n_text"
android:layout_width="0dp" android:layout_height="fill_parent" android:layout_weight="1"
android:orientation="vertical" android:gravity="center_vertical">
<TextView android:id="@+id/n_title"
android:layout_width="fill_parent" android:layout_height="wrap_content"
android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold"
android:maxLines="2" android:ellipsize="end" />
<TextView android:id="@+id/n_sub"
android:layout_width="fill_parent" android:layout_height="wrap_content"
android:textColor="@color/sub" android:textSize="13sp"
android:singleLine="true" android:ellipsize="end" />
</LinearLayout>
<ImageButton android:id="@+id/n_close" android:layout_width="34dp" android:layout_height="34dp"
android:background="@drawable/btn_flat" android:scaleType="center"
android:src="@android:drawable/ic_menu_close_clear_cancel" />
</LinearLayout>
<LinearLayout android:layout_width="fill_parent" android:layout_height="48dp"
android:orientation="horizontal" android:gravity="center">
<Button android:id="@+id/n_back" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold" />
<ImageButton android:id="@+id/n_play" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
android:background="@drawable/btn_flat" android:scaleType="center" android:src="@drawable/ic_play_small" />
<Button android:id="@+id/n_fwd" android:layout_width="0dp" android:layout_height="48dp" android:layout_weight="1"
android:background="@drawable/btn_flat" android:textColor="@color/text" android:textSize="15sp" android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Every size in MiniABS lives here. Tuned for the Galaxy Mini (240x320, 120 dpi), where
Every size in Podlite lives here. Tuned for the Galaxy Mini (240x320, 120 dpi), where
1dp = 0.75 px: 16sp text is 12 px tall, and 48dp touch targets are 36 px (about 7 mm).
-->
<resources>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Android look Podlite's theme (values.xml) builds on. The Android 4.1 build replaces this file
(platform/api16/res/values) with Holo, for Android 4's dialogs, menus and text fields. -->
<resources>
<style name="BaseTheme" parent="@android:style/Theme.NoTitleBar" />
</resources>
+15 -15
View File
@@ -1,23 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">MiniABS</string>
<string name="app_name">Podlite</string>
<!-- Colours taken from the Audiobookshelf icon: gradient #9437FC to #570DCF, waves #BC8AFC -->
<color name="bg">#13111A</color>
<color name="surface">#1D1928</color>
<color name="header">#0C0A12</color>
<color name="line">#2C2638</color>
<color name="text">#EEEBF3</color>
<color name="sub">#ADA6BC</color>
<color name="dim">#736C82</color>
<color name="accent">#7A25EC</color> <!-- fills: tabs, progress, seek bar -->
<color name="accent_light">#BC8AFC</color> <!-- text and small marks on dark backgrounds -->
<color name="accent_top">#9437FC</color> <!-- button gradient, as in the icon -->
<color name="accent_bottom">#5A11D3</color>
<!-- Colours taken from the radio icon: brass #C8964F, dial glow #CC6000, on warm near-black -->
<color name="bg">#15120F</color>
<color name="surface">#211C17</color>
<color name="header">#0E0B09</color>
<color name="line">#342A21</color>
<color name="text">#F2EDE6</color>
<color name="sub">#BFB4A6</color>
<color name="dim">#827567</color>
<color name="accent">#C27A1E</color> <!-- fills: tabs, progress, seek bar -->
<color name="accent_light">#E8A64A</color> <!-- text and small marks on dark backgrounds -->
<color name="accent_top">#D98C2B</color> <!-- button gradient, as in the icon -->
<color name="accent_bottom">#A35F14</color>
<color name="fresh">#6EC1E4</color>
<color name="pressed">#2E2642</color>
<color name="pressed">#3B2D20</color>
<style name="MiniTheme" parent="@android:style/Theme.NoTitleBar">
<style name="MiniTheme" parent="@style/BaseTheme">
<item name="android:windowBackground">@color/bg</item>
<item name="android:textColor">@color/text</item>
<item name="android:textColorPrimary">@color/text</item>
+2 -2
View File
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory android:title="Reprodución">
<PreferenceCategory android:key="playback" android:title="Reprodución">
<ListPreference
android:key="skip_back"
android:title="Retroceder"
@@ -50,7 +50,7 @@
<PreferenceCategory android:title="Acerca de">
<Preference
android:key="about"
android:title="MiniABS 1.5"
android:title="Podlite 1.10"
android:summary="Un pequeno cliente de podcasts de Audiobookshelf para Android 2.2+" />
</PreferenceCategory>
</PreferenceScreen>
+63
View File
@@ -0,0 +1,63 @@
# Podlite (Android) — spec
The short contract for working on this app. `README.md` is the user guide; this file says what the app is
for, what limits it lives within, and what must not break.
## Goal
A small Audiobookshelf podcast client for the Samsung Galaxy Mini whose progress stays in sync with the
server and other apps.
## Target and hard limits
- Samsung Galaxy Mini GT-S5570 and other Android 2.2+ phones (API 8), 240×320 ldpi.
- Native Java, no libraries beyond the bundled TLS stack. All sizes in `res/values/dimens.xml`.
- Galician interface; warm dark colours with an amber accent (from the radio icon).
## Current scope (1.12)
- Login with user/password (refreshes 2.26+ short-lived tokens; older servers' long-lived token) or an API key.
- HTTP on the LAN; bundled TLS 1.2 for `https://` (Mozilla roots); other certificates shown by fingerprint,
trusted once (trusting the issuing CA so renewals work), forgettable in Settings.
- Loopback stream proxy for HTTPS, forwarding seeks with a fresh token per request.
- Tabs: Latest (continue listening + newest), Shows, Saved (SD-card downloads, Wi-Fi only by default).
- Player: skip back/forward (amounts in Settings), seek, show notes, sleep timer, mark played, close.
- Progress sync every 15 s and on pause; offline positions sent later.
- Headset buttons; pauses for calls and unplugging.
## Android 4.1 build
- `build/Podlite-api16.apk` (minSdk/targetSdk 16) next to `build/Podlite.apk` (API 8): shared `src/`, per-level
`platform/api8|api16/…/Platform.java` with the same methods, `platform/api16/res` overlaid. ExoPlayer with speed and
skip silences, notification and lock screen with cover and buttons, Holo, ⋮ without a Menu key, metered networks.
## Out of scope
- Audiobooks, HLS/transcoded streams (so no Opus); playback speed on Android 2.x (it can't).
## Must not break
- An episode only counts as played within a minute of its real end; drop-outs and cut files are retried or
reported, never marked played. A stall of 12 s is reported.
- Package `com.miniabs` and `miniabs.keystore` (password `miniabs`): updates install over MiniABS.
- `src/com/miniabs/net/` is a copy of `tlskit`: change it in `../tlskit` and reinstall.
- `platform/api8` and `platform/api16` keep the same `Platform` methods; shared code calls no API the API 8 jar lacks
(the api8 compile and Dalvik check catch it). Each build's `versionCode` is the manifest's × 100 + level, same key;
both APKs are listed in `../tenda-lite/repo/apps.json`.
- The Android 4.1 build targets API 16: network on the main thread is allowed in `Platform.init`; keep the desktop
tests' files (`Util`, `Api`…) free of `Platform`.
- Installs to internal storage.
## Code map
`Api` · `PlayerService` · `StreamProxy` · `DownloadService` / `Downloads` · `Trust` / `TrustPrompt` · `Prefs` ·
activities Home, Podcast, Player, Login, Settings · `net/` tlskit · `tools/make_icons.py`, `translate_gl.py`.
## Build, test, release
- `ANDROID_JAR=/path/to/android-8/android.jar ./build.sh` (JDK, aapt, dx, zipalign, apksigner, `BC_SRC`).
- `JSON_JAR=… test/run_tests.sh`: API, JSON, progress, HTTPS trust and proxy against `test/mock_abs.py`,
with good, expired, wrong-host, forged and self-signed certificates (`test/make_pki.sh`).
- Release: raise `versionCode`/`versionName`, build, add `changes` in `../tenda-lite/repo/apps.json`, commit
and push, `../tenda-lite/repo/publish.sh`.
- Siblings: `podlite-ios`, `podlite-mac`; behaviour fixes are backported between them.
+4 -4
View File
@@ -26,7 +26,7 @@ import android.os.Build;
/** Audiobookshelf REST client (API as of server 2.x, including the 2.26+ token system). */
final class Api {
static final String VERSION = "1.7";
static final String VERSION = "1.11";
static class ApiException extends IOException {
final int code;
@@ -64,7 +64,7 @@ final class Api {
conn.setUseCaches(false);
conn.setInstanceFollowRedirects(true);
setMethod(conn, method);
conn.setRequestProperty("User-Agent", "MiniABS/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
conn.setRequestProperty("User-Agent", "Podlite/" + VERSION + " (Android " + Build.VERSION.RELEASE + ")");
return conn;
}
@@ -397,7 +397,7 @@ final class Api {
private static JSONObject deviceInfo(Context c) throws JSONException {
return new JSONObject()
.put("deviceId", Prefs.get(c).deviceId())
.put("clientName", "MiniABS")
.put("clientName", "Podlite")
.put("clientVersion", VERSION)
.put("manufacturer", Build.MANUFACTURER)
.put("model", Build.MODEL)
@@ -416,7 +416,7 @@ final class Api {
.put("forceDirectPlay", true)
.put("forceTranscode", false)
.put("supportedMimeTypes", mimes)
.put("mediaPlayer", "MiniABS");
.put("mediaPlayer", "Podlite");
} catch (JSONException ignored) {
}
return obj(call(c, "POST", "/api/items/" + e.itemId + "/play/" + e.id, body, 200,
+22 -1
View File
@@ -18,6 +18,27 @@ abstract class BaseActivity extends Activity {
protected final Handler handler = new Handler();
private boolean ticking;
@Override
protected void onCreate(android.os.Bundle b) {
Platform.init(this);
super.onCreate(b);
}
@Override
protected void onPostCreate(android.os.Bundle b) {
super.onPostCreate(b);
// phones without a Menu key (some Android 4 ones): a ⋮ in the header opens the options menu
View menu = findViewById(R.id.menu);
if (menu instanceof ImageButton && Platform.needsMenuButton(this)) {
float d = getResources().getDisplayMetrics().density;
((ImageButton) menu).setImageDrawable(new Dots(getResources().getColor(R.color.sub), (int) (24 * d)));
menu.setVisibility(View.VISIBLE);
menu.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { openOptionsMenu(); }
});
}
}
private View mini;
private TextView miniTitle, miniSub;
private ImageButton miniPlay;
@@ -166,7 +187,7 @@ abstract class BaseActivity extends Activity {
playEpisode(e);
return true;
case M_DOWNLOAD:
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
if (Prefs.get(this).wifiOnly() && !Platform.unmetered(this)) {
new AlertDialog.Builder(this)
.setMessage("Non estás nunha wifi. Descargar con datos móbiles esta vez?")
.setPositiveButton("Descargar", new DialogInterface.OnClickListener() {
+20
View File
@@ -69,6 +69,26 @@ final class Covers {
});
}
interface Callback { void onCover(Bitmap b); }
/** The cover as a bitmap, on the main thread (null if covers are off or it can't be had). Sizes: 64, 128, 320. */
static void load(Context c, final String itemId, int px, final Callback cb) {
if (!Prefs.get(c).covers() || itemId == null || itemId.length() == 0) { cb.onCover(null); return; }
final int size = px <= 64 ? 64 : px <= 128 ? 128 : 320;
final String key = itemId + "_" + size;
Bitmap b;
synchronized (cache) { b = cache.get(key); }
if (b != null) { cb.onCover(b); return; }
final Context app = c.getApplicationContext();
pool.execute(new Runnable() {
public void run() {
final Bitmap bmp = fetch(app, itemId, size, key);
if (bmp != null) synchronized (cache) { cache.put(key, bmp); }
ui.post(new Runnable() { public void run() { cb.onCover(bmp); } });
}
});
}
private static Bitmap fetch(Context c, String itemId, int size, String key) {
File dir = new File(c.getCacheDir(), "covers");
File f = new File(dir, key + ".jpg");
+31
View File
@@ -0,0 +1,31 @@
package com.miniabs;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
/** ⋮: the options-menu button on phones without a Menu key (Android 4 build). */
final class Dots extends Drawable {
private final Paint p = new Paint(Paint.ANTI_ALIAS_FLAG);
private final int size;
Dots(int color, int sizePx) {
p.setColor(color);
size = sizePx;
}
@Override public void draw(Canvas c) {
Rect b = getBounds();
float cx = b.exactCenterX(), cy = b.exactCenterY(), r = size * 0.09f, gap = size * 0.3f;
for (int i = -1; i <= 1; i++) c.drawCircle(cx, cy + i * gap, r, p);
}
@Override public int getIntrinsicWidth() { return size; }
@Override public int getIntrinsicHeight() { return size; }
@Override public void setAlpha(int a) { p.setAlpha(a); }
@Override public void setColorFilter(ColorFilter f) { p.setColorFilter(f); }
@Override public int getOpacity() { return PixelFormat.TRANSLUCENT; }
}
+10 -4
View File
@@ -22,6 +22,12 @@ import android.os.PowerManager;
/** Downloads episodes one at a time to the SD card, resuming partial files. */
public class DownloadService extends IntentService {
@Override
public void onCreate() {
super.onCreate();
Platform.init(this);
}
private static final int NOTIF_ID = 2;
private static final List<Episode> queue = new ArrayList<Episode>();
@@ -31,7 +37,7 @@ public class DownloadService extends IntentService {
private static volatile String cancelId;
public DownloadService() {
super("MiniABS downloads");
super("Podlite downloads");
}
static void enqueue(Context c, Episode e) {
@@ -105,9 +111,9 @@ public class DownloadService extends IntentService {
percent = 0;
cancelId = null;
PowerManager.WakeLock wake = ((PowerManager) getSystemService(POWER_SERVICE))
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "MiniABS download");
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Podlite download");
WifiManager.WifiLock wifi = ((WifiManager) getSystemService(WIFI_SERVICE))
.createWifiLock(WifiManager.WIFI_MODE_FULL, "MiniABS download");
.createWifiLock(WifiManager.WIFI_MODE_FULL, "Podlite download");
wake.acquire();
wifi.acquire();
try {
@@ -130,7 +136,7 @@ public class DownloadService extends IntentService {
}
private void download(Episode e) throws IOException {
if (Prefs.get(this).wifiOnly() && !Util.onWifi(this)) {
if (Prefs.get(this).wifiOnly() && !Platform.unmetered(this)) {
throw new IOException("non hai wifi (Axustes: Descargar só con wifi)");
}
File dir = Downloads.dir(this);
+48
View File
@@ -0,0 +1,48 @@
package com.miniabs;
import java.io.File;
/**
* The audio player behind {@link PlayerService}, one per opened episode. Android 2.x builds use
* {@link MediaPlayerEngine}; the Android 4.1 build uses ExoPlayer (platform/api16, ExoEngine), see
* {@link Platform#newEngine}. Called on the main thread only, and calls its listener there.
*/
interface Engine {
interface Listener {
/** Ready to play (once per {@link #prepare}). */
void onPrepared(Engine e);
/** Played to the end (or what the player took for the end: PlayerService checks). */
void onCompletion(Engine e);
/** Playback failed. {@code decoder}: this engine can't decode the audio; another engine might. */
void onError(Engine e, String message, boolean decoder);
}
void setListener(Listener l);
/** The audio: a file, or an http:// address. One of the two. */
void setSource(File file, String url) throws Exception;
void prepare();
void start();
void pause();
void seekTo(int ms);
int position();
/** The audio's length in ms, 0 if not known. */
int duration();
/** How much of a stream is loaded, in percent. */
int bufferedPercent();
void setVolume(float v);
/** Playback speed (1 = normal) and skipping silences; Android 2.x's player can't, and ignores them. */
void setSpeed(float speed, boolean skipSilence);
void release();
}
+1 -1
View File
@@ -3,7 +3,7 @@ package com.miniabs;
import org.json.JSONException;
import org.json.JSONObject;
/** One podcast episode, trimmed down to what MiniABS needs. */
/** One podcast episode, trimmed down to what Podlite needs. */
final class Episode {
String itemId = ""; // library item (the podcast) id
String id = ""; // episode id
+2 -2
View File
@@ -101,7 +101,7 @@ public class HomeActivity extends BaseActivity {
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
if (intent.getBooleanExtra("exit", false)) { closeApp(); return; } // "Pechar MiniABS" from the player
if (intent.getBooleanExtra("exit", false)) { closeApp(); return; } // "Pechar Podlite" from the player
if (intent.hasExtra("tab")) selectTab(intent.getIntExtra("tab", TAB_LATEST));
}
@@ -496,7 +496,7 @@ public class HomeActivity extends BaseActivity {
menu.add(0, 2, 0, "Biblioteca").setIcon(android.R.drawable.ic_menu_agenda);
menu.add(0, 3, 0, "En reprodución").setIcon(android.R.drawable.ic_media_play);
menu.add(0, 4, 0, "Axustes").setIcon(android.R.drawable.ic_menu_preferences);
menu.add(0, 5, 0, "Pechar MiniABS").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
menu.add(0, 5, 0, "Pechar Podlite").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
return true;
}
+4
View File
@@ -27,8 +27,12 @@ public class MediaButtonReceiver extends BroadcastReceiver {
action = PlayerService.A_BACK;
break;
case KeyEvent.KEYCODE_MEDIA_STOP:
case 127: // KEYCODE_MEDIA_PAUSE (Android 3+: the lock screen, newer headsets)
action = PlayerService.A_PAUSE;
break;
case 126: // KEYCODE_MEDIA_PLAY
action = PlayerService.A_TOGGLE;
break;
default:
return;
}
+73
View File
@@ -0,0 +1,73 @@
package com.miniabs;
import java.io.File;
import java.io.FileInputStream;
import android.content.Context;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.os.PowerManager;
/** Android's own MediaPlayer: every Android version. No speed control (Android 2.x can't). */
final class MediaPlayerEngine implements Engine, MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener,
MediaPlayer.OnErrorListener, MediaPlayer.OnBufferingUpdateListener {
private final MediaPlayer mp = new MediaPlayer();
private Listener listener;
private int buffered;
MediaPlayerEngine(Context c) {
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
mp.setWakeMode(c.getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
mp.setOnPreparedListener(this);
mp.setOnCompletionListener(this);
mp.setOnErrorListener(this);
mp.setOnBufferingUpdateListener(this);
}
public void setListener(Listener l) { listener = l; }
public void setSource(File file, String url) throws Exception {
if (file != null) {
FileInputStream in = new FileInputStream(file);
try { mp.setDataSource(in.getFD()); } finally { in.close(); }
} else {
mp.setDataSource(url);
}
}
public void prepare() { mp.prepareAsync(); }
public void start() { mp.start(); }
public void pause() { mp.pause(); }
public void seekTo(int ms) { mp.seekTo(ms); }
public int position() { return mp.getCurrentPosition(); }
public int duration() { return Math.max(0, mp.getDuration()); }
public int bufferedPercent() { return buffered; }
public void setVolume(float v) { mp.setVolume(v, v); }
public void setSpeed(float speed, boolean skipSilence) {}
public void release() {
listener = null;
try { mp.reset(); } catch (Exception ignored) {}
try { mp.release(); } catch (Exception ignored) {}
}
public void onPrepared(MediaPlayer p) { if (listener != null) listener.onPrepared(this); }
public void onCompletion(MediaPlayer p) { if (listener != null) listener.onCompletion(this); }
public boolean onError(MediaPlayer p, int what, int extra) {
if (listener != null) listener.onError(this, "Erro de reprodución (" + what + "/" + extra + ").", false);
return true;
}
public void onBufferingUpdate(MediaPlayer p, int percent) { buffered = percent; }
}
+1 -1
View File
@@ -16,7 +16,7 @@ import java.util.Map;
import com.miniabs.net.Http;
/**
* An HttpURLConnection backed by the bundled TLS 1.2 client (com.miniabs.net, from Minamp), so
* An HttpURLConnection backed by the bundled TLS 1.2 client (com.miniabs.net, from Plexamp Lite), so
* the existing API, cover, download and stream code keeps its shape while the phone talks to the
* normal https:// address: Android 2.x's own TLS 1.0 can't. Any method works (PATCH too), and
* connections are kept alive between requests.
+38 -2
View File
@@ -22,7 +22,7 @@ public class PlayerActivity extends BaseActivity {
private ImageView cover;
private SeekBar seek;
private ImageButton play;
private Button back, fwd;
private Button back, fwd, speed;
private Episode shown;
private Episode pending; // episode just requested, until the service picks it up
@@ -77,6 +77,14 @@ public class PlayerActivity extends BaseActivity {
refresh();
}
});
speed = (Button) findViewById(R.id.speed);
if (Platform.SPEED) { // Android 4.1 build: ExoPlayer changes speed
speed.setVisibility(View.VISIBLE);
speed.setText(speedLabel(Prefs.get(this).speed()));
speed.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) { speedDialog(); }
});
}
show.setOnClickListener(new View.OnClickListener() {
public void onClick(View v) {
Episode e = current();
@@ -245,7 +253,7 @@ public class PlayerActivity extends BaseActivity {
}
menu.add(0, 4, 0, "Ir ao programa").setIcon(android.R.drawable.ic_menu_view);
if (active) menu.add(0, 5, 0, "Deter").setIcon(android.R.drawable.ic_menu_close_clear_cancel);
menu.add(0, 6, 0, "Pechar MiniABS").setIcon(android.R.drawable.ic_lock_power_off);
menu.add(0, 6, 0, "Pechar Podlite").setIcon(android.R.drawable.ic_lock_power_off);
return true;
}
@@ -289,6 +297,34 @@ public class PlayerActivity extends BaseActivity {
}
}
private static final float[] SPEEDS = {0.8f, 1f, 1.2f, 1.4f, 1.6f, 1.8f, 2f};
/** "1,2×" (Galician decimal comma); "1×" for normal speed. */
private static String speedLabel(float s) {
String n = s == Math.round(s) ? Integer.toString(Math.round(s)) : Float.toString(Math.round(s * 100) / 100f);
return n.replace('.', ',') + "×";
}
private void speedDialog() {
float now = Prefs.get(this).speed();
String[] labels = new String[SPEEDS.length];
int checked = 1;
for (int i = 0; i < SPEEDS.length; i++) {
labels[i] = speedLabel(SPEEDS[i]) + (SPEEDS[i] == 1f ? " (normal)" : "");
if (Math.abs(SPEEDS[i] - now) < 0.01f) checked = i;
}
new AlertDialog.Builder(this).setTitle("Velocidade")
.setSingleChoiceItems(labels, checked, new DialogInterface.OnClickListener() {
public void onClick(DialogInterface d, int which) {
Prefs.get(PlayerActivity.this).setSpeed(SPEEDS[which]);
PlayerService ps = PlayerService.get();
if (ps != null) ps.applySpeed();
speed.setText(speedLabel(SPEEDS[which]));
d.dismiss();
}
}).show();
}
private void sleepDialog() {
final int[] minutes = {0, 5, 10, 15, 30, 45, 60, -1};
String[] labels = {"Desactivado", "5 minutos", "10 minutos", "15 minutos", "30 minutos", "45 minutos", "1 hora",
+79 -57
View File
@@ -1,7 +1,6 @@
package com.miniabs;
import java.io.File;
import java.io.FileInputStream;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -10,28 +9,25 @@ import org.json.JSONObject;
import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.wifi.WifiManager;
import android.os.Handler;
import android.os.IBinder;
import android.os.PowerManager;
import android.os.SystemClock;
/**
* Plays one episode at a time, keeps the Audiobookshelf playback session in
* sync, and falls back to saved files and local progress when offline.
* sync, and falls back to saved files and local progress when offline. The player is an {@link Engine}: Android's
* MediaPlayer, or ExoPlayer (with speed and skipping silences) on the Android 4.1 build.
*/
public class PlayerService extends Service implements MediaPlayer.OnPreparedListener,
MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener, MediaPlayer.OnBufferingUpdateListener,
AudioManager.OnAudioFocusChangeListener {
public class PlayerService extends Service implements Engine.Listener, AudioManager.OnAudioFocusChangeListener {
static final String A_PLAY = "com.miniabs.PLAY";
static final String A_TOGGLE = "com.miniabs.TOGGLE";
@@ -68,8 +64,13 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private WifiManager.WifiLock wifi;
private ComponentName buttons;
private MediaPlayer mp;
private Engine mp;
private boolean prepared;
private String sourceUrl; // what the engine was opened with, to reopen it in the other engine
private boolean fallback; // this episode didn't decode in ExoPlayer: Android's MediaPlayer
private boolean engineSkip; // whether the engine was opened skipping silences
private String coverFor; // the notification and lock screen's cover
private Bitmap cover;
private Episode ep;
private volatile String sessionId;
private String contentUrl; // server-relative stream URL, re-tokenised on retry
@@ -79,7 +80,6 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private int resumeMs; // where to start once prepared
private boolean playOnPrepared;
private int lastPosMs;
private int buffered; // percent
private int generation; // drops results of superseded async work
private int retries;
private long playingSince;
@@ -118,8 +118,9 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
public void onCreate() {
super.onCreate();
instance = this;
Platform.init(this);
am = (AudioManager) getSystemService(AUDIO_SERVICE);
wifi = ((WifiManager) getSystemService(WIFI_SERVICE)).createWifiLock(WifiManager.WIFI_MODE_FULL, "MiniABS");
wifi = ((WifiManager) getSystemService(WIFI_SERVICE)).createWifiLock(WifiManager.WIFI_MODE_FULL, "Podlite");
wifi.setReferenceCounted(false);
buttons = new ComponentName(this, MediaButtonReceiver.class);
registerReceiver(noisy, new IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY));
@@ -137,6 +138,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
unregisterReceiver(noisy);
} catch (Exception ignored) {
}
Platform.lockScreen(this, null, null, IDLE);
am.unregisterMediaButtonEventReceiver(buttons);
dropFocus();
wifi.release();
@@ -179,7 +181,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
int state() { return state; }
String status() { return status; }
boolean isLocal() { return local; }
int bufferedPercent() { return local ? 100 : buffered; }
int bufferedPercent() { return local ? 100 : mp != null ? mp.bufferedPercent() : 0; }
boolean isPlaying() {
return state == PLAYING;
@@ -188,7 +190,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
int positionMs() {
if (mp != null && prepared) {
try {
return mp.getCurrentPosition();
return mp.position();
} catch (Exception ignored) {
}
}
@@ -199,7 +201,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
if (ep != null && ep.duration > 0) return (int) (ep.duration * 1000);
if (mp != null && prepared) {
try {
return mp.getDuration();
return mp.duration();
} catch (Exception ignored) {
}
}
@@ -231,6 +233,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
sessionId = null;
contentUrl = null;
retries = 0;
fallback = false;
lastPosMs = 0;
resumeMs = 0;
Prefs.get(this).setLastEpisode(e);
@@ -313,6 +316,7 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
wifi.release();
stopForeground(true);
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).cancel(NOTIF_ID);
Platform.lockScreen(this, null, null, IDLE);
ep = null;
stopSelf();
}
@@ -450,35 +454,29 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void prepare(String url, String note) {
releasePlayer();
mp = new MediaPlayer();
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
mp.setWakeMode(getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK);
mp.setOnPreparedListener(this);
mp.setOnCompletionListener(this);
mp.setOnErrorListener(this);
mp.setOnBufferingUpdateListener(this);
sourceUrl = url;
mp = Platform.newEngine(getApplicationContext(), fallback);
mp.setListener(this);
// before the audio is opened: ExoPlayer sets up skipping silences only then
Prefs prefs = Prefs.get(this);
engineSkip = prefs.skipSilence();
mp.setSpeed(prefs.speed(), engineSkip);
try {
if (local) {
FileInputStream in = new FileInputStream(url);
try {
mp.setDataSource(in.getFD());
} finally {
in.close();
}
mp.setSource(new File(url), null);
} else {
mp.setDataSource(url);
mp.setSource(null, url);
wifi.acquire();
}
state = LOADING;
status = note != null ? note : local ? "Abrindo o ficheiro gardado…" : "Cargando…";
mp.prepareAsync();
mp.prepare();
} catch (Exception ex) {
fail("Non se pode abrir o audio: " + Util.describe(ex));
}
}
@Override
public void onPrepared(MediaPlayer p) {
public void onPrepared(Engine p) {
if (p != mp) return;
prepared = true;
int dur = durationMs();
@@ -498,6 +496,22 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
}
}
/** Speed or skipping silences changed in the player screen or Settings. Speed changes as it plays; skipping
* silences only takes effect when the audio is opened, so the episode is reopened here at the same spot. */
void applySpeed() {
Prefs p = Prefs.get(this);
if (mp == null) return;
if (Platform.SPEED && prepared && sourceUrl != null && p.skipSilence() != engineSkip) {
boolean wasPlaying = state == PLAYING;
resumeMs = positionMs();
lastPosMs = resumeMs;
playOnPrepared = wasPlaying;
prepare(sourceUrl, null);
return;
}
mp.setSpeed(p.speed(), engineSkip);
}
private void start() {
if (mp == null) return;
if (!focusHeld) {
@@ -506,7 +520,8 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
}
am.registerMediaButtonEventReceiver(buttons);
try {
mp.setVolume(1f, 1f);
mp.setVolume(1f);
applySpeed();
mp.start();
} catch (Exception ex) {
fail("Fallou a reprodución: " + Util.describe(ex));
@@ -533,26 +548,15 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
private void releasePlayer() {
if (mp != null) {
if (prepared) lastPosMs = positionMs();
try {
mp.reset();
mp.release();
} catch (Exception ignored) {
}
mp.release();
}
mp = null;
prepared = false;
buffered = 0;
}
// --------------------------------------------------------------- player callbacks
@Override
public void onBufferingUpdate(MediaPlayer p, int percent) {
if (p == mp) buffered = percent;
}
@Override
public void onCompletion(MediaPlayer p) {
public void onCompletion(Engine p) {
if (p != mp) return;
int pos = positionMs(), dur = durationMs();
// Only playback that got to (nearly) the end counts as listened; anything else is never
@@ -577,16 +581,24 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
showNotification(false);
}
@Override
public boolean onError(MediaPlayer p, int what, int extra) {
if (p != mp) return true;
public void onError(Engine p, String message, boolean decoder) {
if (p != mp) return;
if (prepared) lastPosMs = positionMs();
if (decoder && !fallback && !(p instanceof MediaPlayerEngine) && sourceUrl != null) {
// ExoPlayer can't decode this episode on this phone: Android's own player may (other decoders)
com.miniabs.net.DiagLog.log("ExoPlayer can't decode \"" + ep.title + "\" (" + message + "): MediaPlayer instead");
fallback = true;
boolean wasPlaying = state == PLAYING || playOnPrepared;
resumeMs = lastPosMs;
playOnPrepared = wasPlaying;
prepare(sourceUrl, null);
return;
}
if (!local && contentUrl != null) {
retryStream("Perdeuse a conexión, reconectando…");
} else {
fail("Erro de reprodución (" + what + "/" + extra + ").");
fail(message);
}
return true;
}
/** Re-opens the stream at the last position with a fresh token. Gives up after three tries. */
@@ -647,11 +659,11 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
}
break;
case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK:
if (mp != null && state == PLAYING) mp.setVolume(0.2f, 0.2f);
if (mp != null && state == PLAYING) mp.setVolume(0.2f);
break;
case AudioManager.AUDIOFOCUS_GAIN:
focusHeld = true;
if (mp != null) mp.setVolume(1f, 1f);
if (mp != null) mp.setVolume(1f);
if (pausedByFocus) {
pausedByFocus = false;
if (state == PAUSED) start();
@@ -767,12 +779,21 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
// --------------------------------------------------------------- notification
/** The notification and the lock screen show the episode and the state. */
private void showNotification(boolean foreground) {
if (ep == null) return;
Notification n = new Notification(R.drawable.ic_stat, null, 0);
n.flags |= foreground ? Notification.FLAG_ONGOING_EVENT : 0;
Intent i = new Intent(this, PlayerActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
PendingIntent pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_UPDATE_CURRENT);
if (!ep.itemId.equals(coverFor)) {
coverFor = ep.itemId;
cover = null;
final String want = ep.itemId;
Covers.load(this, want, Platform.COVER_PX, new Covers.Callback() {
public void onCover(Bitmap b) {
if (b == null || !want.equals(coverFor) || instance != PlayerService.this || ep == null) return;
cover = b;
showNotification(state == PLAYING || state == LOADING);
}
});
}
String what;
switch (state) {
case PLAYING: what = "Reproducindo"; break;
@@ -781,12 +802,13 @@ public class PlayerService extends Service implements MediaPlayer.OnPreparedList
case ERROR: what = "Detido: toca para tentalo de novo"; break;
default: what = "En pausa"; break;
}
n.setLatestEventInfo(this, ep.title, Util.join(what, ep.show), pi);
Notification n = Platform.playerNotification(this, ep, what, cover, state, foreground);
if (foreground) {
startForeground(NOTIF_ID, n);
} else {
stopForeground(false);
((NotificationManager) getSystemService(NOTIFICATION_SERVICE)).notify(NOTIF_ID, n);
}
Platform.lockScreen(this, ep, cover, state);
}
}
+8
View File
@@ -41,6 +41,14 @@ final class Prefs {
String libraryId() { return s("library_id"); }
String libraryName() { return s("library_name"); }
/** Playback speed (Android 4.1 build): 1 = normal. */
float speed() {
try { return Float.parseFloat(sp.getString("speed", "1")); } catch (Exception e) { return 1f; }
}
void setSpeed(float s) { sp.edit().putString("speed", Float.toString(s)).commit(); }
/** Skip the silences in episodes (Android 4.1 build). */
boolean skipSilence() { return sp.getBoolean("skip_silence", false); }
int skipBack() { return num("skip_back", 15); }
int skipFwd() { return num("skip_fwd", 30); }
+20
View File
@@ -28,6 +28,26 @@ public class SettingsActivity extends PreferenceActivity {
back.setOnPreferenceChangeListener(skipSummary);
fwd.setOnPreferenceChangeListener(skipSummary);
if (Platform.SPEED) { // Android 4.1 build: ExoPlayer can leave out the silences
android.preference.CheckBoxPreference silence = new android.preference.CheckBoxPreference(this);
silence.setKey("skip_silence");
silence.setTitle("Saltar os silencios");
silence.setSummary("Os episodios acaban antes sen perder nada");
silence.setDefaultValue(false);
((android.preference.PreferenceCategory) findPreference("playback")).addPreference(silence);
silence.setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
public boolean onPreferenceChange(Preference pref, final Object value) {
new android.os.Handler().post(new Runnable() { // after the new value is saved
public void run() {
PlayerService ps = PlayerService.get();
if (ps != null) ps.applySpeed();
}
});
return true;
}
});
}
findPreference("covers").setOnPreferenceChangeListener(new Preference.OnPreferenceChangeListener() {
public boolean onPreferenceChange(Preference pref, Object value) {
Covers.clearMemory();
+3 -3
View File
@@ -16,7 +16,7 @@ import android.content.Context;
/**
* Lets Android's media player stream from an HTTPS server. The player can't do HTTPS before
* Android 3.1 and wouldn't use MiniABS's certificate trust anyway, so it plays
* Android 3.1 and wouldn't use Podlite's certificate trust anyway, so it plays
* http://127.0.0.1:PORT/<random id> and this proxy fetches the real stream (forwarding seeks as
* Range requests) with a fresh sign-in token each time.
*/
@@ -37,7 +37,7 @@ final class StreamProxy implements Runnable {
private StreamProxy(Context c) throws IOException {
this.c = c.getApplicationContext();
server = new ServerSocket(0, 8, InetAddress.getByName("127.0.0.1"));
Thread t = com.miniabs.net.Threads.create(this, "MiniABS proxy");
Thread t = com.miniabs.net.Threads.create(this, "Podlite proxy");
t.setDaemon(true);
t.start();
}
@@ -90,7 +90,7 @@ final class StreamProxy implements Runnable {
public void run() {
serve(s);
}
}, "MiniABS proxy conn");
}, "Podlite proxy conn");
t.setDaemon(true);
t.start();
} catch (IOException e) {
+1 -1
View File
@@ -23,7 +23,7 @@ import com.miniabs.net.AssetRoots;
import com.miniabs.net.CertVerifier;
/**
* Decides which HTTPS certificates MiniABS accepts, for the bundled TLS 1.2 client:
* Decides which HTTPS certificates Podlite accepts, for the bundled TLS 1.2 client:
* 1. certificates chaining to Mozilla's roots (assets/roots; Android 2.3's own list predates Let's Encrypt);
* 2. certificates the user chose to trust after checking the fingerprint ("pins").
*/
+18 -6
View File
@@ -4,35 +4,47 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import android.content.Context;
/** Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der */
/**
* Mozilla roots shipped as assets/roots/<sha1(subject)>-<n>.der
*
* Roots are read from the APK once and kept: an app installed on the SD card loses its APK while the
* card is shared over USB, and a failed read must not leave every server "untrusted" until a restart.
*/
public final class AssetRoots implements CertVerifier.RootStore {
private final Context ctx;
private Set<String> names;
private Set<String> names; // asset file names, once listed
private final Map<String, List<byte[]>> found = new HashMap<String, List<byte[]>>(); // by subject hash; null = none
public AssetRoots(Context c) { ctx = c.getApplicationContext(); }
public synchronized List<byte[]> find(byte[] subjectDer) {
String h = CertVerifier.sha1Hex(subjectDer);
if (found.containsKey(h)) return found.get(h);
try {
if (names == null) {
names = new HashSet<String>();
String[] l = ctx.getAssets().list("roots");
if (l != null) for (int i = 0; i < l.length; i++) names.add(l[i]);
if (l == null || l.length == 0) return null; // APK unreadable right now: try again next time
Set<String> n = new HashSet<String>();
for (int i = 0; i < l.length; i++) n.add(l[i]);
names = n;
}
String h = CertVerifier.sha1Hex(subjectDer);
List<byte[]> out = null;
for (int i = 0; names.contains(h + "-" + i + ".der"); i++) {
if (out == null) out = new ArrayList<byte[]>();
out.add(readAll(ctx.getAssets().open("roots/" + h + "-" + i + ".der")));
}
found.put(h, out);
return out;
} catch (IOException e) {
return null;
return null; // not cached: the next handshake reads again
}
}
+1 -1
View File
@@ -37,7 +37,7 @@ import com.minamp.bc.crypto.util.PublicKeyFactory;
* Minimal Web PKI path validation for the bundled TLS stack:
* hostname match, validity window, signature chain up to a bundled Mozilla root.
* Optionally one pinned leaf certificate (SHA-256) is trusted as-is, for self-signed servers.
* Shared with Minamp (tools/tlskit in the iOS ports' history); keep the copies in sync.
* Shared with Plexamp Lite (tools/tlskit in the iOS ports' history); keep the copies in sync.
*/
public final class CertVerifier {
+34 -18
View File
@@ -33,11 +33,11 @@ import com.minamp.bc.tls.crypto.impl.bc.BcTlsCrypto;
/**
* Small HTTP/1.1 client with its own TLS 1.2 (Bouncy Castle), keep-alive pooling and
* session resumption. Android 2.x's built-in SSL only speaks TLS 1.0 and lacks today's roots,
* so every request in the app goes through here. From Minamp; keep the copies in sync.
* so every request in the app goes through here. From Plexamp Lite; keep the copies in sync.
*/
public final class Http {
public static String USER_AGENT = "Minamp/1.0";
public static String USER_AGENT = "PlexampLite/1.0";
/** Optional HTTP CONNECT proxy, only used when running the networking code on a desktop JVM. */
public static String proxyHost;
@@ -451,19 +451,25 @@ public final class Http {
// ------------------------------------------------------------------ body streams
private static final class FixedStream extends InputStream {
private final Conn c; private long left; private final boolean keepAlive; private boolean closed;
private final Conn c; private long left; private final boolean keepAlive; private volatile boolean closed;
FixedStream(Conn c, long len, boolean keepAlive) {
this.c = c; this.left = len; this.keepAlive = keepAlive;
if (len == 0) finish(true);
}
private void finish(boolean complete) {
private synchronized void finish(boolean complete) {
if (closed) return;
closed = true;
if (complete && keepAlive) release(c); else c.close();
}
synchronized void abort() {
if (closed) return; // done: the connection may already be back in the pool, serving someone else
closed = true;
try { c.socket.close(); } catch (Exception ignored) {}
}
@Override public int read() throws IOException {
byte[] one = new byte[1];
int n = read(one, 0, 1);
@@ -490,16 +496,22 @@ public final class Http {
private static final class ChunkedStream extends InputStream {
private final Conn c; private final boolean keepAlive;
private long chunkLeft = 0; private boolean eof; private boolean closed;
private long chunkLeft = 0; private boolean eof; private volatile boolean closed;
ChunkedStream(Conn c, boolean keepAlive) { this.c = c; this.keepAlive = keepAlive; }
private void finish(boolean complete) {
private synchronized void finish(boolean complete) {
if (closed) return;
closed = true;
if (complete && keepAlive) release(c); else c.close();
}
synchronized void abort() {
if (closed) return; // done: the connection may already be back in the pool, serving someone else
closed = true;
try { c.socket.close(); } catch (Exception ignored) {}
}
private boolean nextChunk() throws IOException {
if (eof) return false;
String line = readLine(c.in);
@@ -551,7 +563,7 @@ public final class Http {
}
private static final class EofStream extends InputStream {
private final Conn c; private boolean closed;
private final Conn c; private volatile boolean closed;
EofStream(Conn c) { this.c = c; }
@Override public int read() throws IOException {
@@ -568,21 +580,25 @@ public final class Http {
return n;
}
@Override public void close() {
@Override public synchronized void close() {
if (!closed) { closed = true; c.close(); }
}
}
/** Close one in-flight response's socket from another thread (used to abort streams). */
public static void abort(Response r) {
if (r == null) return;
InputStream b = r.body;
Conn c = null;
if (b instanceof FixedStream) c = ((FixedStream) b).c;
else if (b instanceof ChunkedStream) c = ((ChunkedStream) b).c;
else if (b instanceof EofStream) c = ((EofStream) b).c;
if (c != null) {
synchronized void abort() {
if (closed) return;
closed = true;
try { c.socket.close(); } catch (Exception ignored) {}
}
}
/** Close one in-flight response's socket from another thread (used to abort streams). A response that has
* already been read to the end is left alone: its connection went back to the pool and may be carrying
* another request by now (closing it cut read-ahead downloads short: "Unexpected end of stream"). */
public static void abort(Response r) {
if (r == null) return;
InputStream b = r.body;
if (b instanceof FixedStream) ((FixedStream) b).abort();
else if (b instanceof ChunkedStream) ((ChunkedStream) b).abort();
else if (b instanceof EofStream) ((EofStream) b).abort();
}
}
+2 -2
View File
@@ -10,7 +10,7 @@ import org.json.JSONObject;
import android.content.Context;
/** Runs MiniABS's real API, JSON and progress code against mock_abs.py on the desktop JVM. */
/** Runs Podlite's real API, JSON and progress code against mock_abs.py on the desktop JVM. */
public class ApiTest {
static int pass, fail;
@@ -131,7 +131,7 @@ public class ApiTest {
if (path.startsWith("/api/items/pod1/play/")) play = r;
}
check(play != null && play.getJSONObject("b").getBoolean("forceDirectPlay")
&& play.getJSONObject("b").getJSONObject("deviceInfo").getString("clientName").equals("MiniABS"),
&& play.getJSONObject("b").getJSONObject("deviceInfo").getString("clientName").equals("Podlite"),
"play request asks for direct play and names the client");
check(sync != null && sync.getJSONObject("b").getDouble("currentTime") == 615.5
&& sync.getJSONObject("b").getDouble("timeListened") == 15.2, "progress sync sent");
+1 -1
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# Runs MiniABS's API/JSON/progress/HTTPS code on the desktop JVM against mock_abs.py.
# Runs Podlite's API/JSON/progress/HTTPS code on the desktop JVM against mock_abs.py.
set -e
cd "$(dirname "$0")/.."
OUT=build/test
+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"
+26 -24
View File
@@ -1,9 +1,9 @@
"""Draws MiniABS's PNG icons for ldpi (Galaxy Mini), mdpi and hdpi.
"""Draws Podlite's PNG icons for ldpi (Galaxy Mini), mdpi and hdpi.
The launcher icon (res/drawable-*/ic_launcher.png) comes from the supplied
Audiobookshelf icon pack and is not generated here. The notification icon is
cut out of that same artwork (art/icon_master_512.png): the white headphones
and equalizer become a white silhouette, as Android 2.3's status bar expects.
"Podcasts Radio" icon pack (master in art/icon-master.png) and is not generated
here. The notification icon is a white silhouette of that radio, as Android
2.3's status bar expects.
Android 2.x has no vector drawables, so the player icons are rendered at 4x and
downsampled for smooth edges.
@@ -15,7 +15,7 @@ from PIL import Image, ImageDraw
RES = sys.argv[1]
ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
DENS = {"ldpi": 0.75, "mdpi": 1.0, "hdpi": 1.5}
LILAC = (188, 138, 252, 255) # the icon's sound-wave colour (#BC8AFC)
AMBER = (232, 166, 74, 255) # accent_light (#E8A64A), from the radio icon's brass
WHITE = (255, 255, 255, 255)
SS = 4 # supersampling
@@ -33,22 +33,24 @@ def save(img, px, folder, name):
def status_silhouette():
"""White mask of the icon's light parts (headphones, bars, waves) on transparency."""
src = Image.open(os.path.join(ROOT, "art", "icon_master_512.png")).convert("RGBA")
out = Image.new("RGBA", src.size, (0, 0, 0, 0))
sp, op = src.load(), out.load()
for y in range(src.size[1]):
for x in range(src.size[0]):
r, g, b, a = sp[x, y]
# the purple background has a low green channel; the white/lilac artwork a high one
level = min(255, max(0, (g - 80) * 255 // 50))
op[x, y] = (255, 255, 255, level * a // 255)
box = out.getbbox()
out = out.crop(box)
side = max(out.size)
sq = Image.new("RGBA", (side, side), (0, 0, 0, 0))
sq.paste(out, ((side - out.size[0]) // 2, (side - out.size[1]) // 2))
return sq
"""White silhouette of the launcher icon's old radio (handle, grille, two knobs) on transparency:
Android 2.3's status bar expects a light glyph, not the colour artwork."""
S = 24 * 32 # drawn on a 24-unit grid, supersampled
u = lambda v: int(round(v * 32))
m = Image.new("L", (S, S), 0)
d = ImageDraw.Draw(m)
d.arc((u(6), u(1.2), u(18), u(11)), 200, 340, fill=255, width=u(1.8)) # carrying handle
d.rounded_rectangle((u(1.5), u(6.5), u(22.5), u(21.5)), radius=u(3.5), fill=255)
d.rounded_rectangle((u(4), u(9), u(14), u(19)), radius=u(1.2), fill=0) # grille window
for i in range(3): # grille bars
y = 10.6 + i * 2.9
d.rounded_rectangle((u(5.2), u(y), u(12.8), u(y + 1.5)), radius=u(0.7), fill=255)
for cy, r in ((11.6, 2.4), (17.0, 2.0)): # knobs as rings
d.ellipse((u(18 - r), u(cy - r), u(18 + r), u(cy + r)), fill=0)
d.ellipse((u(18 - r + 1.1), u(cy - r + 1.1), u(18 + r - 1.1), u(cy + r - 1.1)), fill=255)
out = Image.new("RGBA", (S, S), (255, 255, 255, 0))
out.putalpha(m)
return out
def status(sil, px, folder):
@@ -89,9 +91,9 @@ def refresh(px, folder):
sil = status_silhouette()
for folder, k in DENS.items():
status(sil, round(25 * k), folder)
play(round(32 * k), folder, "ic_play", WHITE) # on the purple play button
play(round(32 * k), folder, "ic_play", WHITE) # on the amber play button
pause(round(32 * k), folder, "ic_pause", WHITE)
play(round(38 * k), folder, "ic_play_small", LILAC) # mini player bar, on dark
pause(round(38 * k), folder, "ic_pause_small", LILAC)
play(round(38 * k), folder, "ic_play_small", AMBER) # mini player bar, on dark
pause(round(38 * k), folder, "ic_pause_small", AMBER)
refresh(round(30 * k), folder)
print("icons ok")
+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])
+1 -1
View File
@@ -1,4 +1,4 @@
"""One-off: translates MiniABS's interface strings to Galician in place.
"""One-off: translates Podlite's interface strings to Galician in place.
Each (file, old, new) must match at least once, so nothing is silently missed."""
import os
import re